#!/usr/bin/env bash
# codex-accounts — manage the codex-multiacc account pool (OpenAI Codex CLI).
# Subcommands: list status add import adopt dedupe remove login expired relogin
#              sync verify limits post-sync health self-update
#              export-credential import-credential
set -u
# lib/codex_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")"
# Selects the codex pool (~/.codex-accounts, codex_audit.py, the ChatGPT usage
# endpoint) in everything lib/common.sh defines. Exported so subshells agree.
export MULTIACC_PROVIDER=codex
# shellcheck source=lib/common.sh
. "$REPO_DIR/lib/common.sh"

usage() {
  cat <<'EOF'
codex-accounts — multi-account pool manager for codex-multiacc (OpenAI Codex CLI)

USAGE
  codex-accounts list [--json]              brief account list (--json: machine-readable)
  codex-accounts status [--json]            full health: auth, per-window limits, markers
  codex-accounts add [email] [--browser] [--force]
      login-FIRST: runs the Codex DEVICE-CODE sign-in by default — it prints a URL
      + one-time code you can open in ANY browser (this machine, your laptop, a
      phone), so it works identically on a local Mac, over SSH, and on servers.
      Registers only after the signed-in email is read back. Email is OPTIONAL
      (derived from the sign-in). Stores an auto-refreshing auth.json valid on
      THIS machine. Duplicates refused.
      --browser uses the localhost browser-callback flow instead (only works when
      the browser runs on THIS machine — the callback goes to localhost:1455).
  codex-accounts login <acct-NN> [--browser] [--force]
      complete/refresh auth for an existing account (device-code flow, or --browser)
  codex-accounts expired [--quiet]
      which accounts CANNOT authenticate (dead refresh grant, no login on this
      machine) and why. These are excluded from selection — `codex` never runs
      under them. Exits 1 when any account needs a human. --quiet prints bare ids.
  codex-accounts relogin [acct-NN ...] [--all] [--browser] [--yes]
      sign in again, one account at a time (device-code flow by default). With no
      arguments it re-authenticates exactly what `expired` lists; --all covers
      every account. Syncs once at the end.
  codex-accounts import <email> [opts]      register an account, optionally with auth
      --id acct-NN          explicit id (default: next free)
      --home mac|server     which machine owns the login (default: this one)
      --auth PATH           existing auth.json to adopt
      --mode copy|move|link how to adopt --auth (default copy)
      --no-sync             skip the automatic server sync
  codex-accounts adopt <acct-NN>            make acct-NN THIS machine's existing
                                            default ~/.codex login (dir symlink —
                                            single credential file, no grant fork)
  codex-accounts export-credential <acct-NN> [--out PATH] [--identity-only]
      codex has NO portable credential class: auth.json carries a rotating refresh
      token, so a copy breaks both machines. This ALWAYS refuses a credential
      export (exit 3) and says what to do instead (sign in on that machine with
      the device-code flow). --identity-only exports the registry entry
      (id/email/home) so a daemon can seed the account list on another machine.
  codex-accounts import-credential [acct-NN] [--in PATH|-] [--home mac|server]
                                   [--force] [--no-sync]
      install a blob from export-credential (stdin by default). For codex that is
      always an identity-only blob: the account is registered and then needs ONE
      interactive sign-in here (codex-accounts login <acct-NN>).
  codex-accounts remove <acct-NN> [--yes]   delete account (propagates to server)
  codex-accounts dedupe [--yes]             remove any account registered twice
                                            (same email), keeping one per email
  codex-accounts configure [--max-subagents N] [--include-global] [--check] [--json]
      explicitly manage Codex subagent concurrency for this pool (Python 3.11+).
      Applies to existing and future manifest accounts; other settings stay intact.
      --include-global also updates ~/.codex/config.toml; --check only reads settings.
      Without --max-subagents, reads back the current policy. Changes stay local
      until sync distributes the opted-in pool policy to its configured targets.
  codex-accounts sync [--no-server]         push manifest+seeds to the sync target AND
                                            any manifest 'peers' (extra machines). Mac
                                            only. auth.json is machine-local, NEVER
                                            synced. 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.
  codex-accounts verify [--quick]           auth matrix; full mode runs a real
                                            `codex exec` per account
  codex-accounts limits [--quiet] [--force] [--json]
      refresh usage windows from the ChatGPT usage endpoint, apply >=90% markers.
      When the pool has parked the account (a window at/over the threshold, a
      finished limit, or a client-reported limit that lifts more than an hour from
      now), automatically redeems an earned usage-limit reset if the account has
      one, using an idempotent request.
      Auto-refreshes long-expired access tokens via the OAuth 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.
  codex-accounts health                     limits + full verify; logs to health.log
  codex-accounts self-update                update this npm/git install; logs to update.log
  codex-accounts post-sync                  (server side) seed dirs, fix perms, quick verify
  codex-accounts mcp add <name> [--provider claude|codex|both] [--scope user]
                          [-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 ([mcp_servers.<name>] in every account's config.toml), syncs the
      registry to the server and peers (their post-sync applies it). Default
      --provider both: the same server is registered in the claude pool too.
      A stock `codex mcp add|remove` run under the shim is mirrored into the
      registry the same way — it no longer lands in one random account.
      Project scope is claude-only: Codex reads no per-project MCP config.
      -e takes ONE variable per flag (KEY=VAL). Exit 3 = registry saved, but an account
      could not be reconciled (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.
      The codex registry syncs only to codex sync targets; a runner Mac's local-only
      codex pool is fed by app-robot's overlay (`mcp import-local --owner app-robot`).
  codex-accounts mcp add-json <name> '<json>' [--provider ...]
  codex-accounts mcp remove <name> [--provider ...] [--user-only]
      unregister AND retire EVERYWHERE: every account (fleet-wide after sync) drops the
      server, even one that was hand-added to a single account (--user-only spares a
      claude project-local copy in the claude pool).
  codex-accounts mcp list [--json]          effective registry (+ machine-local overlay)
  codex-accounts mcp apply [--account-dir DIR]... [--all]
                                            re-apply the registry now (repair drift)
  codex-accounts mcp import-local --owner NAME   (stdin JSON) machine-local overlay for
                                            a runner daemon; applied here, never synced

ENV
  CODEX_ACCOUNTS_ROOT   pool root, overriding ~/.codex-accounts — one isolated pool
                        per app-robot instance on a shared machine (legacy spelling
                        CODEX_ACCOUNTS_DIR still works)
  CODEX_MULTIACC_SYNC_TARGET   sync target (user@host), overriding the manifest;
                        'none' = local-only, nothing is pushed anywhere
  CODEX_MULTIACC_SYNC_ROOT / _SYNC_REPO   remote pool root / addon repo for it
  CODEX_ACCOUNT         pin the shim to one account
  CODEX_SHIM_RETRY=0    disable the `codex exec` auto-retry
  CODEX_MULTIACC_DISABLE=1  bypass the shim entirely
  CODEX_MULTIACC_AUTO_RESET=0  disable automatic usage-reset redemption
  CODEX_MULTIACC_MCP=0   disable the MCP registry (no reconcile at seed/launch, no mirror)
EOF
}

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

cmd_configure() {
  require_manifest
  local apply=0 check=0 arg rc
  for arg in "$@"; do
    case "$arg" in --max-subagents|--max-subagents=*) apply=1 ;; --check) check=1 ;; esac
  done
  if [ "$apply" = "1" ] && [ "$check" = "0" ]; then
    mutate_lock || die "could not acquire the account settings lock; try again"
    trap mutate_unlock EXIT
  fi
  "$PYBIN" "$LIB_DIR/codex_settings.py" configure "$ACC_ROOT" "$@"
  rc=$?
  mutate_unlock
  trap - EXIT
  return "$rc"
}

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
}

# ---- per-account login lock ----------------------------------------------------
# Two concurrent logins for the SAME account corrupt each other: the later one
# snapshots the OLD credential and its abort/refusal "restores" that stale
# credential over the fresh one the first sign-in produced. A ceremony can take
# minutes, so the lock is only reclaimed after 30 minutes (holder presumed dead).
# Stale reclaim is ATOMIC between contenders: rename steals the stale lock
# (exactly one mv succeeds); the loser finds a live lock and refuses. Release is
# OWNERSHIP-GUARDED (owner.<pid> marker), so a process can never remove a lock a
# later contender legitimately re-took.
login_lock_acquire() { # $1 = lock dir; nonzero when someone else holds it
  local L="$1"
  if ! mkdir "$L" 2>/dev/null; then
    [ $(( $(epoch_now) - $(file_mtime "$L") )) -gt 1800 ] || return 1
    mv "$L" "$L.stale.$$" 2>/dev/null || return 1
    rm -rf "$L.stale.$$" 2>/dev/null
    mkdir "$L" 2>/dev/null || return 1
  fi
  : > "$L/owner.$$" 2>/dev/null || true
  return 0
}
login_lock_release() { # $1 = lock dir; removes it ONLY if this process owns it
  local L="$1"
  [ -e "$L/owner.$$" ] && rm -rf "$L" 2>/dev/null
  return 0
}
login_lock_busy() { # $1 = acct dir; true when a LIVE login ceremony holds it
  [ -d "$1/.login-lock" ] \
    && [ $(( $(epoch_now) - $(file_mtime "$1/.login-lock") )) -le 1800 ]
}

auto_sync() { # best effort after mutations, Mac only, loud on failure
  [ "${CODEX_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 'codex-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: codex-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 codex_audit import audit_account   # noqa: E402  (shared with the shim's rule)
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'])
    apath = os.path.join(d, 'auth.json')
    auth = 'chatgpt' if (os.path.isfile(apath) and os.path.getsize(apath) > 0) else 'NONE'
    limited = os.path.isfile(os.path.join(d, '.limited'))
    st = audit_account(root, a, machine=machine)
    flags = []
    if st['state'] == 'expired':
        flags.append('EXPIRED-LOGIN')
    elif st['state'] == 'blocked':
        flags.append('ORG-BLOCKED')
    elif st['state'] == 'missing':
        flags.append('NO-LOGIN')
    if limited:
        flags.append('LIMITED')
    print(f"{a['id']}  {a['email']:<28} home={a.get('home','?'):<7} "
          f"auth={auth:<11} {' '.join(flags)}")
bad = [a for a in accounts
       if audit_account(root, a, machine=machine)['state']
       in ('expired', 'blocked', 'missing')]
if bad:
    print()
    print(f"{len(bad)} account(s) are NOT usable — details: codex-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 'codex-accounts dedupe'")
PYEOF
}

cmd_status() {
  require_manifest
  local json=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --json) json=1; shift ;;
      *) die "unknown option: $1 (usage: codex-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 codex_audit import audit_account, jwt_claims   # noqa: E402
machine = sys.argv[4]
now = time.time()

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 window at/above => account excluded)")
print()
needs_login = []
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', 'blocked', 'missing'):
        needs_login.append(aid)
    banner = {'ok': '', 'remote': '  [not logged in here — login lives elsewhere]',
              'missing': '  ** NO LOGIN — codex-accounts relogin %s **' % aid,
              'expired': '  ** LOGIN EXPIRED — codex-accounts relogin %s **' % aid,
              'blocked': '  ** ORG BLOCKED — Codex disabled for this account by an admin **',
              }[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, 'auth.json')
    if os.path.isfile(cpath):
        try:
            c = json.load(open(cpath))
            tokens = c.get('tokens') or {}
            claims = jwt_claims(tokens.get('access_token'))
            exp = float(claims.get('exp') or 0)
            plan = ((jwt_claims(tokens.get('id_token'))
                     .get('https://api.openai.com/auth') or {})
                    .get('chatgpt_plan_type') or '?')
            state = 'fresh' if exp > now else 'stale (auto-refreshes on use)'
            rstate = 'present' if tokens.get('refresh_token') else 'MISSING — re-login needed'
            when = time.strftime('%Y-%m-%d', time.gmtime(exp)) if exp else '?'
            print(f"  chatgpt auth: {state} (access token until {when}); "
                  f"refresh token {rstate}; plan {plan}")
        except Exception as e:
            print(f"  chatgpt auth: unreadable ({e})")
    else:
        print("  chatgpt auth: none on this machine")
    lpath = os.path.join(d, 'limits.json')
    if os.path.isfile(lpath):
        try:
            lim = json.load(open(lpath))
            age = int(now - lim.get('fetched_at', 0))
            parts = [f"{b['name']}={b['percent']}%" for b in lim.get('buckets', [])]
            # A no_data document (all-zero buckets with no reported reset windows —
            # the 2026-09-04 claude incident, mirrored here for parity) carries no
            # percent fields at all. Printing "max None%" would read like a healthy
            # account sitting at zero, which is the misreading that handed picks to
            # exhausted accounts; 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)'}  [{age}s old, {head}]")
        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):
        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: codex-accounts expired")
PYEOF
}

# The signed-in identity of a codex config dir, read OFFLINE from the id_token in
# auth.json (no network, no real binary needed). Prints '' when unknown.
codex_dir_email() { # $1 = config dir
  "$PYBIN" - "$LIB_DIR" "$1/auth.json" <<'PYEOF' 2>/dev/null || echo ""
import sys
sys.path = [sys.argv[1]] + [p for p in sys.path if p not in ('', '.')]
from codex_audit import auth_email   # noqa: E402
print(auth_email(sys.argv[2]))
PYEOF
}

# Interactive sign-in ceremony with CODEX_HOME pointed at the account dir, so the
# resulting auth.json lands exactly there. DEFAULT is the device-code flow
# (`codex login --device-auth`): it prints a URL + one-time code that can be opened
# in ANY browser, so it behaves the same on a local Mac, over SSH, and on servers —
# the browser-callback flow only works when the browser runs on this machine
# (localhost:1455), which is why it is the opt-in (--browser), not the default.
# Success is a credential that can AUTHENTICATE (codex_audit's rule), not merely a
# file that exists.
run_codex_login_ceremony() { # $1 = config dir, $2 = email hint, $3 = mode (device|browser)
  local d="$1" hint="${2:-}" mode="${3:-device}" real
  real="$(find_real_codex "$_self")" || { warn "real codex binary not found"; return 1; }
  if [ "$mode" = "browser" ]; then
    cat <<'TIP'
A browser will open (or a sign-in link will be printed) for the Codex ChatGPT
login. The sign-in must finish in a browser running on THIS machine (the
callback goes to localhost:1455) — from SSH/remote sessions use the default
device-code flow instead (drop --browser). Sign in as the account you're
adding; if the browser is already signed into a different ChatGPT account, use
"Sign in with a different account" (or a private window). This draws on the
ChatGPT subscription — no API keys.
TIP
  else
    cat <<'TIP'
Device sign-in (works from anywhere): a URL and a one-time code will appear
below. Open the URL in ANY browser — this machine, your laptop, even a phone —
sign in to the ChatGPT account you're adding (use a private window if that
browser is already signed into a different ChatGPT account), and enter the code.
TIP
  fi
  [ -n "$hint" ] && printf 'Sign in as: %s\n' "$hint"
  # Run codex login DIRECTLY on the user's terminal (no PTY wrapper): the credential
  # is written to <dir>/auth.json, we capture nothing.
  if [ "$mode" = "browser" ]; then
    CODEX_HOME="$d" CODEX_SHIM_ACTIVE=1 "$real" login || true
  else
    CODEX_HOME="$d" CODEX_SHIM_ACTIVE=1 "$real" login --device-auth || true
  fi
  # Re-login targets already have a (dead) auth.json on disk, 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.
  [ -s "$d/auth.json" ] || return 1
  chmod 600 "$d/auth.json" 2>/dev/null || true
  creds_alive "$d"
}

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 mode="device"
  while [ $# -gt 0 ]; do
    case "$1" in
      --force) force=1; shift ;;
      --browser) mode="browser"; shift ;;  # localhost callback flow (browser on THIS machine)
      --device) mode="device"; shift ;;    # accepted for compatibility; device is the default
      --*) die "unknown option: $1" ;;
      *)
        if [ -z "$email" ]; then email="$1"; shift
        else die "unexpected argument: $1 (usage: codex-accounts add [email] [--browser] [--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 'codex-accounts login $owner' to re-authenticate it)."
      return 0
    fi
  fi
  if [ ! -t 0 ] && [ -z "${CODEX_MULTIACC_FORCE_TTY:-}" ]; then
    die "add is interactive (it completes sign-in before registering) — run it from a terminal"
  fi
  find_real_codex "$_self" >/dev/null || die "real codex 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.
  # 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

  echo "Preparing $id for $elabel — NOTHING is registered until login completes."
  run_codex_login_ceremony "$d" "$email" "$mode" || die "login failed or aborted — nothing was created"
  got="$(codex_dir_email "$d")"
  if [ -z "$got" ]; then
    if [ -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 sign-in to 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
    # The sign-in itself succeeded and produced a fresh credential for an account
    # that is already registered — don't throw it away: save it to that account,
    # exactly as `login $owner` would have. This machine now runs $owner.
    # Needs the owner's login lock (non-blocking): writing under a mid-ceremony
    # login for that account would corrupt it.
    local od="$ACC_ROOT/$owner"
    seed_account_dir "$od"
    if login_lock_acquire "$od/.login-lock" && mv -f "$d/auth.json" "$od/auth.json" 2>/dev/null; then
      chmod 600 "$od/auth.json" 2>/dev/null || true
      clear_auth_markers "$od"
      login_lock_release "$od/.login-lock"
      mutate_unlock
      log_to ops.log "add redirect: signed in as $got (already $owner) — credential saved to $owner"
      echo "$got is already added as $owner — the fresh sign-in was saved to $owner (usable on this machine now)."
    else
      login_lock_release "$od/.login-lock"
      mutate_unlock
      echo "$got is already added as $owner — skipping (nothing added)."
    fi
    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
  log_to ops.log "add $id $got (auth-verified)"
  auto_sync
  cat <<EOF
Registered $id for $got (sign-in verified) — usable immediately.
Optional:
  codex-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).
  [ -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: codex-accounts import <email> [--id acct-NN] [--home mac|server] [--auth PATH] [--mode copy|move|link] [--no-sync]"
  shift
  local id="" home="" auth="" mode="copy" no_sync=0 force=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --id) id="$2"; shift 2 ;;
      --home) home="$2"; shift 2 ;;
      --auth) auth="$2"; shift 2 ;;
      --mode) mode="$2"; shift 2 ;;
      --no-sync) no_sync=1; shift ;;
      --force) force=1; shift ;;
      *) die "unknown option: $1" ;;
    esac
  done
  [ -n "$home" ] || home="$(machine_kind)"
  # Validate ALL inputs before creating anything, so a bad auth path leaves no
  # half-made account dir behind.
  [ -n "$auth" ] && { [ -f "$auth" ] || die "auth file not found: $auth"; }
  case "$mode" in copy|move|link) ;; *) die "mode must be copy|move|link" ;; esac
  # STAGE the auth payload BEFORE taking the lock: the lock's stale-reclaim window
  # is 30s, so the critical section below must stay milliseconds-fast (renames and
  # a manifest write) — never a copy of caller-sized data.
  local stage="" link_target=""
  if [ -n "$auth" ]; then
    case "$mode" in
      link) link_target="$(canon_path "$auth")" ;;
      *)
        mkdir -p "$ACC_ROOT/tmp"
        # mktemp, not a predictable name: the staged file briefly holds a live
        # credential, and a predictable path in a shared pool root could be
        # pre-planted as a symlink and written through.
        stage="$(mktemp "$ACC_ROOT/tmp/import-auth.XXXXXX")" \
          || die "could not create a staging file under $ACC_ROOT/tmp"
        IMPORT_STAGE="$stage"
        trap import_cred_cleanup EXIT
        case "$mode" in
          copy) ( umask 077; cat "$auth" > "$stage" ) ;;
          move) cat "$auth" > "$stage" && rm -f "$auth" ;;
        esac || { import_cred_cleanup; trap - EXIT; die "could not stage $auth (mode=$mode) — nothing registered"; }
        chmod 600 "$stage" 2>/dev/null || true
        ;;
    esac
  fi
  # Id allocation + duplicate check + manifest write run under the mutation lock, so
  # two parallel imports can never claim the same slot or drop each other's entry.
  IMPORT_STAGE="$stage"
  mutate_lock || { import_cred_cleanup; trap - EXIT; die "could not acquire the account lock (another op is stuck?) — try again"; }
  # A CONSTANT trap program (import_cred_cleanup): interpolating the staged path would
  # let a pool root containing a quote inject shell code into the trap body.
  trap import_cred_cleanup EXIT
  fail_locked() { import_cred_cleanup; trap - EXIT; die "$@"; }
  [ -n "$id" ] || id="$(next_id)"
  case "$id" in acct-[0-9][0-9]) ;; *) fail_locked "id must look like acct-NN" ;; esac
  local owner
  owner="$(email_owner "$email")"
  if [ -n "$owner" ] && [ "$owner" != "$id" ] && [ "$force" != "1" ]; then
    fail_locked "$email is already registered as $owner — use --id $owner to update it, or --force to register a duplicate"
  fi
  local d="$ACC_ROOT/$id"
  seed_account_dir "$d"
  if [ -n "$stage" ]; then
    # A failed adopt must not register an account that claims auth it does not have.
    mv -f "$stage" "$d/auth.json" \
      || fail_locked "could not adopt $auth into $d (mode=$mode) — nothing registered"
    stage=""
    IMPORT_STAGE=""
    chmod 600 "$d/auth.json" 2>/dev/null || true
  elif [ -n "$link_target" ]; then
    ln -sf "$link_target" "$d/auth.json" \
      || fail_locked "could not link $auth into $d — nothing registered"
  fi
  manifest_add_account "$id" "$email" "$home" \
    || fail_locked "manifest update failed — $id was seeded but NOT registered (re-run import)"
  mutate_unlock
  trap - EXIT
  log_to ops.log "import $id $email home=$home auth=${auth:+yes} mode=$mode"
  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" <<'PYEOF' 2>/dev/null
import json, os, re, sys
manifest, root = sys.argv[1], sys.argv[2]
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):
    p = os.path.join(root, aid, 'auth.json')
    return os.path.isfile(p) and os.path.getsize(p) > 0
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 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 (~/.codex) without
  # forking its OAuth grant: the account dir becomes a symlink to ~/.codex, so
  # there is exactly one auth.json no matter which path refreshes it.
  require_manifest
  local id="${1:-}"
  [ -n "$id" ] || die "usage: codex-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/.codex"
  [ -s "$default/auth.json" ] || die "no default login at $default to adopt"
  if [ -L "$d" ]; then
    echo "$id already adopted ($(readlink "$d"))"
    return 0
  fi
  if [ -d "$d" ]; then
    [ -s "$d/auth.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: codex-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
  # Never delete an account out from under a live sign-in ceremony: the login's
  # cleanup would then operate on a removed (or later recreated) path.
  login_lock_busy "$ACC_ROOT/$id" \
    && die "a login for $id is in progress — finish or abort it first"
  if [ -L "$ACC_ROOT/$id" ]; then
    rm -f "${ACC_ROOT:?}/${id:?}"   # adopted account: remove the symlink, never the target
  else
    rm -rf "${ACC_ROOT:?}/${id:?}"
  fi
  manifest_del_account "$id"
  log_to ops.log "remove $id"
  auto_sync
  echo "Removed $id."
}

cmd_login() {
  # Complete (or refresh) auth for an EXISTING account: the device-code flow by
  # default (works locally and over SSH), or --browser for the localhost callback
  # flow. Verifies the signed-in email matches the manifest.
  require_manifest
  local id="" force=0 mode="device"
  while [ $# -gt 0 ]; do
    case "$1" in
      --force) force=1; shift ;;
      --browser) mode="browser"; shift ;;
      --device) mode="device"; shift ;;   # accepted for compatibility; device is the default
      --*) die "unknown option: $1" ;;
      *) if [ -z "$id" ]; then id="$1"; shift; else die "unexpected argument: $1"; fi ;;
    esac
  done
  [ -n "$id" ] || die "usage: codex-accounts login <acct-NN> [--browser] [--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="$("$PYBIN" - "$MANIFEST" "$id" <<'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
)"
  # PER-ACCOUNT login lock (observed in the field: parallel relogin runs left a
  # months-dead token inside a minutes-old auth.json — see login_lock_acquire).
  local llock="$d/.login-lock"
  login_lock_acquire "$llock" \
    || die "another login for $id is already in progress (this terminal or another) — finish or abort it first"
  # Released on EVERY exit: normal returns, die (exit fires EXIT), Ctrl-C (the INT
  # handler exits, which fires EXIT). Runs in a subshell under relogin, so the trap
  # scope is exactly this one login. Ownership-guarded: never removes a lock a
  # later contender re-took.
  # shellcheck disable=SC2064
  trap "login_lock_release '$llock'" EXIT

  echo "Sign in as $email for $id."
  # Snapshot the credential BEFORE the ceremony: `codex login` writes auth.json into
  # the dir directly, so a sign-in to the WRONG account would otherwise leave that
  # account's credential installed under $id — selectable, and burning the wrong
  # subscription. On any refused/aborted outcome the prior state is restored exactly;
  # a snapshot that cannot be taken ABORTS (never risk destroying the only copy).
  LOGIN_SNAP=""
  LOGIN_HAD_CRED=0
  LOGIN_TARGET="$d"
  if [ -f "$d/auth.json" ]; then
    LOGIN_HAD_CRED=1
    LOGIN_SNAP="$ACC_ROOT/tmp/login-snap.$$.json"
    mkdir -p "$ACC_ROOT/tmp"
    ( umask 077; cp "$d/auth.json" "$LOGIN_SNAP" 2>/dev/null ) \
      || die "cannot snapshot the current credential ($d/auth.json) — aborting before any sign-in"
  fi
  restore_snap() { # put the pre-ceremony credential state back, exactly
    if [ "$LOGIN_HAD_CRED" = "1" ]; then
      [ -f "$LOGIN_SNAP" ] && mv -f "$LOGIN_SNAP" "$LOGIN_TARGET/auth.json" 2>/dev/null
    else
      rm -f "$LOGIN_TARGET/auth.json" 2>/dev/null
    fi
    rm -f "$LOGIN_SNAP" 2>/dev/null
  }
  # The transaction window (ceremony -> identity check) must not leave a wrong
  # credential behind on Ctrl-C/kill either.
  trap 'restore_snap; exit 130' INT TERM
  run_codex_login_ceremony "$d" "$email" "$mode" \
    || { restore_snap; trap - INT TERM; die "login failed or aborted (no working credential landed) — nothing changed"; }
  got="$(codex_dir_email "$d")"
  # Case-insensitive identity match (same rule the duplicate check applies).
  got_lc="$(printf '%s' "$got" | tr '[:upper:]' '[:lower:]')"
  email_lc="$(printf '%s' "$email" | tr '[:upper:]' '[:lower:]')"
  if [ -n "$got" ] && [ "$got_lc" != "$email_lc" ] && [ "$force" != "1" ]; then
    # Signed in as a DIFFERENT account. If that account is registered in this pool,
    # the sign-in was not a mistake worth discarding — route the fresh credential to
    # ITS account dir (what `login <owner>` would have produced), restore this
    # account's prior state, and report honestly that THIS account is still unfixed.
    local owner
    owner="$(email_owner "$got")"
    if [ -n "$owner" ] && [ "$owner" != "$id" ]; then
      # The redirect mutates the OWNER's credential, so it needs the owner's login
      # lock too (non-blocking: if a login for that account is mid-ceremony, writing
      # under it would recreate exactly the corruption this lock prevents — skip the
      # redirect and fall through to the plain refusal instead).
      local od="$ACC_ROOT/$owner"
      seed_account_dir "$od"
      if login_lock_acquire "$od/.login-lock"; then
        if mv -f "$d/auth.json" "$od/auth.json" 2>/dev/null; then
          chmod 600 "$od/auth.json" 2>/dev/null || true
          clear_auth_markers "$od"
          login_lock_release "$od/.login-lock"
          restore_snap
          trap - INT TERM
          log_to ops.log "login redirect: signed in as $got -> credential saved to $owner (target was $id)"
          echo "you signed in as $got — that is $owner, so the credential was saved to $owner (usable now)."
          echo "$id ($email) still needs its own sign-in: codex-accounts login $id"
          return 1
        fi
        login_lock_release "$od/.login-lock"
      else
        echo "note: a login for $owner is in progress — not saving this credential there."
      fi
    fi
    restore_snap
    trap - INT TERM
    die "you signed in as $got but $id is $email — nothing saved, previous credential restored (use --force to override)"
  fi
  if [ -z "$got" ] && [ "$force" != "1" ]; then
    restore_snap
    trap - INT TERM
    die "signed in, but the account identity could not be read back — refusing to call $id fixed (retry, or pass --force)"
  fi
  trap - INT TERM
  rm -f "$LOGIN_SNAP" 2>/dev/null
  clear_auth_markers "$d"
  echo "$id login saved (auth.json, this machine, auto-refreshing)."
  log_to ops.log "login $id verified=${got:-unverified} mode=$mode"
  auto_sync
}

cmd_expired() {
  # Which accounts CANNOT authenticate right now — the pool's re-login worklist.
  # Same rule the shim selects by (lib/codex_audit.py), so what is listed here is
  # exactly what is excluded from selection.
  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: codex-accounts expired [--quiet])" ;;
    esac
  done
  local rows bad
  rows="$(account_audit)" || 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: codex-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" || $4 == "blocked" || $4 == "missing") { 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 }
    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 }
    {
      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 a login on this machine can authenticate.\n", ok
      else printf "\n%d account(s) cannot be used, %d fine.\n", bad, ok
      if (remote > 0) {
        printf "\nNot logged in here on purpose (another machine owns the login):\n"
        printf "%s", rem
      }
      if (relogin > 0) {
        printf "\nRe-authenticate them:\n"
        printf "  codex-accounts relogin             # every account that needs it\n"
        printf "  codex-accounts relogin acct-NN     # just one\n"
      }
    }'
  # Exit 1 when something needs a human, 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 browser=0 ids=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --all) all=1; shift ;;
      --yes|-y) yes=1; shift ;;
      --browser) browser=1; shift ;;
      --device) browser=0; shift ;;   # accepted for compatibility; device is the default
      --*) 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
      # 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 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
  # Accounts that were BROKEN when this run started: if one of them turns healthy
  # mid-run (a redirected sign-in from an earlier iteration landed its credential),
  # asking the user to sign it in again would be pure friction — it gets skipped.
  # Accounts that were healthy at start (explicit ids / --all) are never skipped:
  # the user asked to re-authenticate them on purpose.
  local pre_bad
  pre_bad=" $(accounts_needing_login 2>/dev/null | tr '\n' ' ') "
  # One sync at the end instead of one per account: each auto_sync is an ssh round trip.
  local prev_no_sync="${CODEX_MULTIACC_NO_SYNC:-0}" failed="" done_ok=0
  export CODEX_MULTIACC_NO_SYNC=1
  for id in $ids; do
    echo
    echo "=== $id ==============================================================="
    case "$pre_bad" in
      *" $id "*)
        if [ "$(account_audit 2>/dev/null | awk -F'\t' -v i="$id" '$1 == i { print $4 }')" = "ok" ]; then
          echo "$id already has a working login (a credential landed during an earlier sign-in) — skipping."
          done_ok=$((done_ok + 1))
          continue
        fi ;;
    esac
    if [ "$browser" = "1" ]; then
      ( cmd_login "$id" --browser ) && done_ok=$((done_ok + 1)) || failed="$failed $id"
    else
      ( cmd_login "$id" ) && done_ok=$((done_ok + 1)) || failed="$failed $id"
    fi
  done
  export CODEX_MULTIACC_NO_SYNC="$prev_no_sync"
  [ "$prev_no_sync" = "0" ] && unset CODEX_MULTIACC_NO_SYNC
  echo
  echo "re-authenticated $done_ok of $count account(s)."
  if [ -n "$failed" ]; then
    warn "still failing:$failed (re-run: codex-accounts relogin$failed)"
  fi
  [ "$done_ok" -gt 0 ] && auto_sync
  [ -z "$failed" ]
}

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
  # 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
  "$PYBIN" - "$ACC_ROOT" "$threshold" "$quiet" "$USAGE_URL" "$force" "$LIB_DIR" <<'PYEOF' 2>>"$ACC_ROOT/limits.log"
import base64, datetime, json, os, sys, time, urllib.request

root, threshold, quiet, url, force, lib_dir = (sys.argv[1], int(sys.argv[2]),
    sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1', sys.argv[6])
sys.path.insert(0, lib_dir)
from codex_reset_telemetry import refresh_reset_credits
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('CODEX_MULTIACC_MIN_FETCH', '240'))

# OAuth refresh-token grant — the same endpoint + public client id the Codex CLI
# itself uses to keep auth.json alive. An account that sits idle past its
# access-token TTL would otherwise drop out of telemetry (stale data ranks
# neutral, so truly-idle accounts lose selection to busy-but-fresh ones).
TOKEN_URL = os.environ.get('CODEX_MULTIACC_TOKEN_URL',
                           'https://auth.openai.com/oauth/token')
CLIENT_ID = os.environ.get('CODEX_MULTIACC_CLIENT_ID',
                           'app_EMoamEEZ73f0CkXaXp7hrann')
# 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 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('CODEX_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
    try:
        return datetime.datetime.fromisoformat(str(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 the claude writer 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 window as each client names it: codex labels a short window
    # `client:5h`, claude's own type is `five_hour`, and a bare `session` covers the
    # writer's own marker. Kept identical to bin/claude-accounts' copy.
    b = marker_bucket(txt).lower()
    return 'five_hour' in b or '5h' in b or 'session' in b


def _prior_mark(path):
    """The existing .client-limit-cleared epoch (0 when absent/garbled): the watermark
    only ever moves forward, so a reset stamped earlier in a pass is never undone."""
    try:
        return int(open(path).read().split()[0])
    except Exception:
        return 0


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, and the codex
    # client writes client:7d — a label bin/codex's client_limit_scan derives from the
    # rollout's window_minutes (>= 1440), NOT the raw `primary`/`secondary` key names it
    # wrote before 2026-09-04, which map to no fixed window and so were never protected
    # here. Any future weekly* name is caught too. A legacy client:primary/secondary
    # marker is therefore not weekly to this rule and stays clearable, exactly as it was
    # before the guard existed; it expires at its own reset regardless.
    b = marker_bucket(txt).lower()
    return 'seven_day' in b or '7d' in b or 'weekly' in b

def jwt_claims(token):
    try:
        payload = str(token).split('.')[1]
        payload += '=' * (-len(payload) % 4)
        claims = json.loads(base64.urlsafe_b64decode(payload))
        return claims if isinstance(claims, dict) else {}
    except Exception:
        return {}

def jwt_exp(token):
    try:
        return float(jwt_claims(token).get('exp') or 0)
    except (TypeError, ValueError):
        return 0.0

# `.expired` — the persistent "this account cannot authenticate" marker the shim
# honors. Written only for a PROVEN dead grant (no refresh token, or an
# invalid_grant from the token endpoint), never for a transient hiccup.
def mark_expired(d, slug, detail=''):
    mpath = os.path.join(d, '.expired')
    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 an org-blocked one: those accounts authenticate perfectly (telemetry works),
    they are just barred from Codex inference, so telemetry says nothing about them.
    Only a passing `verify` (a real call) or a re-login lifts that."""
    mpath = os.path.join(d, '.expired')
    try:
        if 'reason=org-blocked' in open(mpath, errors='replace').read():
            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 refresh_oauth(aid, d, cpath):
    """Refresh a long-expired access token via the OAuth refresh-token grant and
    persist the ROTATED credential atomically (0600). Returns the new bearer, or
    None (fail open: the on-disk 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 = json.load(open(cpath))
        tokens = doc.get('tokens')
        # Present-but-null/non-object tokens (interrupted or reset credential
        # write) must degrade THIS account only, like every other malformed input.
        if not isinstance(doc, dict) or not isinstance(tokens, dict):
            return None
    except Exception:
        return None
    if not tokens.get('access_token'):
        return None
    if not tokens.get('refresh_token'):
        mark_expired(d, 'no-refresh-token',
                     'credential has no refresh token and its access token expired')
        return None
    if jwt_exp(tokens['access_token']) > now - REFRESH_MIN_EXPIRED:
        return None  # not expired long enough to prove no live session owns it
    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')

    # The grant ROTATES the refresh token, so it must not be consumed unless the
    # rotated credential can actually be persisted afterwards: probe that the
    # atomic-write path works (0600 temp file in the same dir) BEFORE the request.
    # The probe uses its OWN pid-scoped name — never the real .tmp staging path,
    # which a concurrent writer could be mid-flight on.
    probe = f'{cpath}.probe.{os.getpid()}'
    try:
        fd = os.open(probe, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        os.close(fd)
        os.remove(probe)
    except Exception as e:
        back_off(REFRESH_FAIL_BACKOFF, f'cannot persist a rotated credential ({str(e)[:120]}); refresh not attempted')
        return None
    body = json.dumps({'client_id': CLIENT_ID,
                       'grant_type': 'refresh_token',
                       'refresh_token': tokens['refresh_token'],
                       'scope': 'openid profile email'}).encode()
    req = urllib.request.Request(TOKEN_URL, data=body, headers={
        'Content-Type': 'application/json',
        'User-Agent': 'codex-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.
            resp_body = ''
            try:
                resp_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 resp_body:
                mark_expired(d, f'refresh-denied-http-{e.code}',
                             'the refresh grant was refused as invalid_grant (revoked or rotated away)')
            elif denials >= 3:
                mark_expired(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
    tokens['access_token'] = tok
    # The grant ROTATES the refresh token: persist it (and the fresh id_token) or
    # the account is stranded — hence atomic write, and a loud message if it fails.
    if data.get('refresh_token'):
        tokens['refresh_token'] = data['refresh_token']
    if data.get('id_token'):
        tokens['id_token'] = data['id_token']
    doc['tokens'] = tokens
    doc['last_refresh'] = datetime.datetime.now(datetime.timezone.utc) \
        .isoformat().replace('+00:00', 'Z')
    try:
        fd = os.open(cpath + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(fd, 'w') as f:
            json.dump(doc, f, indent=2)
        os.replace(cpath + '.tmp', cpath)
    except Exception as e:
        say(f'{aid}: token refreshed but auth.json 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}: 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 = {}
    if not force:
        age = now - prev.get('fetched_at', 0)
        if age < MIN_FETCH_INTERVAL:
            continue
        retry_at = prev.get('retry_after', 0)
        if retry_at > now:
            say(f'{aid}: backing off after 429 ({int(retry_at - now)}s left); limits left as-is')
            continue

    bearer = None
    account_id = ''
    cpath = os.path.join(d, 'auth.json')
    if os.path.isfile(cpath):
        try:
            c = json.load(open(cpath))
            tokens = c.get('tokens') or {}
            if isinstance(tokens, dict) and tokens.get('access_token') \
               and jwt_exp(tokens['access_token']) > now + 60:
                bearer = tokens['access_token']
            if isinstance(tokens, dict):
                account_id = str(tokens.get('account_id') or '')
        except Exception:
            pass
        if not bearer:
            # 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, cpath)
            except Exception as e:
                say(f'{aid}: oauth refresh failed unexpectedly ({str(e)[:200]}); failing open')
                tok = None
            if tok:
                bearer = tok
    if not bearer:
        # Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
        say(f'{aid}: no fresh bearer (expired or missing chatgpt auth); limits left as-is')
        continue
    if not account_id:
        account_id = str((jwt_claims(bearer).get('https://api.openai.com/auth') or {})
                         .get('chatgpt_account_id') or '')

    headers = {
        'Authorization': 'Bearer ' + bearer,
        'Content-Type': 'application/json',
        'User-Agent': 'codex_cli_rs (codex-multiacc)',
        'originator': 'codex_cli_rs',
    }
    if account_id:
        headers['chatgpt-account-id'] = account_id
    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:
        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(prev.get('backoff', 60)) * 2))
            prev['retry_after'] = int(now + wait)
            prev['backoff'] = wait
            tmp = lpath + '.tmp'
            with open(tmp, 'w') as f:
                json.dump(prev, f, indent=1)
            os.replace(tmp, lpath)
            say(f'{aid}: rate limited (429); backing off {wait}s; failing open')
        else:
            say(f'{aid}: usage fetch failed (HTTP {e.code}); failing open')
        continue
    except Exception as e:
        say(f'{aid}: usage fetch failed ({e}); failing open')
        continue

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

    # Shape-agnostic bucket extraction. The payload nests rate-limit WINDOWS
    # (used_percent + limit_window_seconds + reset_at) under several scopes:
    # the overall rate_limit, code_review_rate_limit, and one per model family in
    # additional_rate_limits[] (each model tracked as its own bucket — the codex
    # analog of the per-model Fable bucket requirement). Window length classifies
    # session (~5h, self-healing) vs weekly (multi-day, expensive): <=6h => session.
    # If the payload reshapes, whatever windows remain are still found (recursive
    # fallback), unparseable entries are skipped, and a payload the code cannot
    # read at all degrades that one account (fail open), never the run.
    def win_bucket(scope, which, win):
        if not isinstance(win, dict):
            return None
        pct = pct_of(win.get('used_percent'))
        if pct is None:
            return None
        try:
            secs = int(win.get('limit_window_seconds') or 0)
        except (TypeError, ValueError):
            secs = 0
        if secs and secs <= 21600:
            dur, group = f'{max(1, secs // 3600)}h', 'session'
        elif secs:
            dur, group = f'{max(1, secs // 86400)}d', 'weekly'
        else:
            # Unknown durable windows default to 'weekly' so they are never under-weighted.
            dur, group = which, 'weekly'
        name = f'{scope}:{dur}' if scope else dur
        try:
            reset_epoch = int(float(win.get('reset_at')))
            reset_known = True
        except (TypeError, ValueError):
            reset_epoch = int(now + (secs or 3600))
            # Synthesized, not reported. Remembered because this writer FORMATS
            # resets_at itself: unlike the claude payload, the finished bucket cannot
            # be re-read later to tell a real window from a placeholder, and the
            # no-data rule below turns on exactly that distinction. Stripped before
            # the document is written so limits.json keeps its published shape.
            reset_known = False
        return {
            'name': name,
            'kind': which,
            'group': group,
            'percent': pct,
            'resets_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(reset_epoch)),
            'resets_epoch': reset_epoch,
            '_reset_known': reset_known,
        }

    def scope_buckets(scope, rl):
        out = []
        if not isinstance(rl, dict):
            return out
        for which in ('primary_window', 'secondary_window'):
            b = win_bucket(scope, which, rl.get(which))
            if b:
                out.append(b)
        # A hard "limit reached / not allowed" verdict without a >=threshold window
        # (payload skew) must still exclude: synthesize a 100% bucket that resets
        # with the scope's furthest-out window (or in 1h if none is readable).
        if (rl.get('limit_reached') is True or rl.get('allowed') is False) \
           and not any(b['percent'] >= threshold for b in out):
            reset_epoch = max([b['resets_epoch'] for b in out] or [int(now + 3600)])
            out.append({
                'name': f'{scope}:limit_reached' if scope else 'limit_reached',
                'kind': 'limit_reached', 'group': 'weekly', 'percent': 100,
                'resets_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(reset_epoch)),
                'resets_epoch': reset_epoch,
                # A server verdict, not a reported window: 100% is informative on its
                # own, so the no-data rule below never depends on this flag here.
                '_reset_known': False,
            })
        return out

    buckets = []
    try:
        buckets += scope_buckets('', data.get('rate_limit'))
        buckets += scope_buckets('code_review', data.get('code_review_rate_limit'))
        for extra in (data.get('additional_rate_limits') or []):
            if not isinstance(extra, dict):
                continue
            scope = str(extra.get('limit_name') or extra.get('metered_feature') or 'model')
            buckets += scope_buckets(scope, extra.get('rate_limit'))
        if not buckets:
            # Fallback for a reshaped payload: find ANY window-shaped object (carries
            # used_percent) anywhere in the document, naming it by its path. Missing
            # one would let an exhausted bucket go unnoticed.
            def walk(node, path):
                if isinstance(node, dict):
                    if 'used_percent' in node:
                        b = win_bucket(':'.join(path[:-1]), path[-1] if path else 'window', node)
                        if b:
                            buckets.append(b)
                        return
                    for k in sorted(node):
                        walk(node[k], path + [str(k)])
                elif isinstance(node, list):
                    for i, item in enumerate(node):
                        walk(item, path + [str(i)])
            walk(data, [])
    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, same reset asymmetry as the claude pool:
    #   max_percent    — peak of ALL buckets; drives >=90% EXCLUSION.
    #   weekly_percent — peak of the durable buckets; the PRIMARY ranking signal.
    #   session_percent— peak of the self-healing ~5h buckets; the session GATE's input:
    #                    the shim ranks only accounts at/under CODEX_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 window the payload actually reported. The
    # claude pool learned this the hard way on 2026-09-04: its usage endpoint answered
    # EVERY bucket `percent: 0, resets_at: null` for acct-13/acct-14 while the client
    # was being rejected on them at their weekly limit, the writer recorded the zeros
    # verbatim, and two provably exhausted accounts led the weekly band for 31 of the
    # last ~60 picks. Real usage always comes with 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 leaves the real buckets ranking as today.
    def informative(b):
        return b['percent'] > 0 or bool(b.get('_reset_known'))

    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 — same rule as the claude writer.
    # 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 windows
    # were all uninformative while its 5h window read 40% was recorded as 40% WEEKLY —
    # a number no window 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 — same
    # asymmetry the claude writer documents, and codex feels it harder. A durable
    # window always exists, so a silent one is an endpoint that DECLINED. A ~5h session
    # window only exists while it is OPEN, and codex does not even keep a placeholder
    # for a closed one: an idle account's payload carries the 7d window and NO session
    # window whatsoever, so `session` here is empty for every quiet account. That made
    # session_percent permanently absent across this pool, and pick_best's quota_known
    # rule needs BOTH readings — so EVERY codex account scored unknown, bestw never left
    # 101, and the band degenerated to the all-gated tie: codex has been picking
    # uniformly at RANDOM rather than by headroom (selection.log: `session=?%` on every
    # ranked line). A session window that is closed has nothing in it, and an exhausted
    # one is never silent — it reports at/near 100% with the reset it is waiting on.
    # So a missing or silent session reads 0 whenever the payload answered at all; a
    # payload where NOTHING answered still writes no signals and stays unknown below.
    session_measured = bool(session)
    if sessionp is None and live:
        sessionp = 0
    try:
        reset_result, reset_view = refresh_reset_credits(d, aid, maxp, data, url, headers, say,
                                                         int(now), threshold)
    except Exception as e:
        say(f'{aid}: usage reset automation failed unexpectedly ({type(e).__name__}); failing open')
        reset_result = {'status': 'error'}
        reset_view = {}
    # Internal only (see win_bucket): limits.json keeps the bucket shape every reader
    # — the shim, lib/report.py, app-robot — already knows.
    for b in buckets:
        b.pop('_reset_known', None)
    out = {'fetched_at': int(now), 'source': 'chatgpt'}
    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 — an idle account has to get back into the band — but
        # nothing may read it as proof of recovery. codex reports no window at all for a
        # closed one, so silence here is the NORMAL shape and says nothing whatsoever
        # about a 429 the client actually received. See the claude writer for the live
        # case that proved it (acct-14, 2026-09-22).
        out['session_inferred'] = True
    if not live:
        # Nothing usable in the entire payload. Keep the diagnostics (fetched_at,
        # source, plan, the raw buckets) and write NONE of the 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 claude 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['plan'] = str(data.get('plan_type') or '')
    out['buckets'] = buckets
    if reset_result.get('status') == 'redeemed':
        # The response proves the reset succeeded, but the usage GET happened before
        # it. Make that snapshot stale immediately so it cannot re-exclude the newly
        # refreshed account; the next scheduled pass replaces it with server truth.
        out['fetched_at'] = 0
        out['auto_reset'] = reset_result
    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 reset_result.get('status') == 'redeemed':
        # bin/codex's rollout scan would otherwise re-create a park from the very
        # rejection this reset answered — whether or not a marker exists right now. The
        # clean pass below only stamps this for an active client marker; a redemption
        # needs it unconditionally (same rule as the claude writer). Never 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, int(time.time()))}\n')
        os.replace(cleared_path + '.tmp', cleared_path)
    offenders = [] if reset_result.get('status') == '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% five-hour window resetting tonight written
        # with a seven-day epoch — so everything that trusts the marker parked
        # the account for days over a window that refills in hours. #20 fixed
        # this for the claude pool and left the codex writer behind; app-robot
        # reads both with the same rule. Among offenders the longest-lived wins,
        # since any bucket over the threshold stays there until its own reset.
        worst = max(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)
        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  — a real codex run was told by the server that this
            #                        window is spent, and recorded the reset it was
            #                        handed. That is first-hand evidence; a usage
            #                        payload that disagrees must not unpark the account
            #                        early and send work straight back into the wall.
            keep = False
            client_recovered = False
            try:
                txt = open(mpath).read()
                first = txt.splitlines()[0] if txt else ''
                active = first.isdigit() and int(first) > now
                # Sync and atomic copies change mtimes, so prefer the marker's own
                # semantic timestamp; old marker formats fall back to mtime.
                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: ONE rule, shared with the claude writer.
                # 2026-09-04 changed this branch — every active client marker used to
                # be kept unconditionally, so a 5h rejection outlived a window that
                # refills in hours. Now it clears on an informative pass after the same
                # confirm delay claude uses, while a 7d one sticks until its reset.
                if reset_result.get('status') == 'redeemed':
                    keep = False   # a confirmed reset is first-hand proof it refilled
                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 one window down: a rejection the CLIENT reported is only
                    # disproved by telemetry that looked at the window it names. With the
                    # session window silent, max_percent is the WEEKLY peak, 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:
                    # The rollout that reported the spent window is STILL on disk, and
                    # the shim's client_limit_scan re-reads its tail on the very next
                    # launch — so deleting the marker without a watermark only rewrites
                    # it seconds later, once per 15-minute pass, forever. Stamp the clear
                    # the way bin/claude-accounts has since #22's five-hour clearing
                    # landed (same filename, same epoch payload); bin/codex then skips
                    # every report at or before it. Codex gained 5h clearing on
                    # 2026-09-04 and needed the identical brake with it.
                    cleared = os.path.join(d, '.client-limit-cleared')
                    with open(cleared + '.tmp', 'w') as f:
                        f.write(f'{max(_prior_mark(cleared), 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'
                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
  # 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_codex "$_self")" || die "real codex binary not found"
  fi
  "$PYBIN" - "$ACC_ROOT" "$quick" "$real" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
import json, os, re, subprocess, sys, tempfile, 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 codex_audit import audit_account   # noqa: E402  (shared with the shim's rule)
machine = sys.argv[5]
now = time.time()
manifest = json.load(open(os.path.join(root, 'accounts.json')))
os.makedirs(os.path.join(root, 'tmp'), exist_ok=True)
failures = 0
tested = 0

# Same failure vocabulary the shim retries on (bin/codex: PARK_AUTH / PARK_ORG).
AUTH_ERR = re.compile(
    r'not (logged|signed) in|authentication (required|failed|error)'
    r'|please run `?codex login|run `?codex login`? to|401|unauthorized'
    r'|token .{0,12}(expired|revoked|invalid)|refresh token.{0,20}(expired|invalid|revoked)'
    r'|invalid_grant|could not refresh|re-?authenticate', re.I)
ORG_ERR = re.compile(
    r'disabled by (your )?(workspace )?admin|admin (has )?disabled'
    r'|(workspace|organization) has disabled (codex|chatgpt)'
    r'|codex.{0,20}disabled for (your|this) (workspace|organization)', re.I)
# EXCULPATORY, and therefore tested FIRST. These say the CLI never reached the
# API at all — a missing native package, a missing node, an unrunnable binary —
# so they are facts about this MACHINE, not about the login. Parking on one
# converts an indeterminate probe into "this login is dead", and that verdict
# does not self-heal: `mark_expired` here writes no `soft_until`, so the shim
# reads it as proven dead and keeps it, while the panel refuses to re-import
# the token until its fingerprint changes.
#
# Not hypothetical. health.log 2026-08-24T06:37:02Z: all four accounts
# `FAIL rc=127 err='env: node: No such file or directory'` — one broken PATH
# reddened the entire matrix, and it escaped parking only because that exact
# string happens not to match AUTH_ERR. `Missing optional dependency
# @openai/codex-<platform>` (my-mini, 2026-09-10) is the same class.
#
# Keep it NARROW. It must never contain a word a genuine auth failure uses
# ("permission denied", "forbidden", a bare "not found"), or a real dead login
# would be excused as a broken machine. Widen only with a test.
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')
    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, 'auth.json')
    has_creds = os.path.isfile(cpath) and os.path.getsize(cpath) > 0
    if not has_creds:
        print(f'{aid} {acct["email"]}: SKIP (no auth on this machine)')
        continue
    tested += 1
    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)
        if st['state'] == 'ok':
            print(f'{aid} {acct["email"]}: OK (quick, chatgpt auth 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['CODEX_HOME'] = d
    env.pop('OPENAI_API_KEY', None)
    env.pop('CODEX_ACCOUNT', None)
    env['CODEX_SHIM_ACTIVE'] = '1'
    # --output-last-message: codex exec prints progress/log lines around the answer,
    # so the agent's final message is read from a file instead of scraping stdout.
    outfile = tempfile.NamedTemporaryFile(prefix='verify.', suffix='.txt',
                                          dir=os.path.join(root, 'tmp'), delete=False)
    outfile.close()
    t0 = time.time()
    try:
        r = subprocess.run([real, 'exec', '--skip-git-repo-check',
                            '--sandbox', 'read-only', '--color', 'never',
                            '-o', outfile.name, 'Reply with exactly: OK'],
                           env=env, capture_output=True, text=True, timeout=240,
                           stdin=subprocess.DEVNULL, cwd=root)
    except subprocess.TimeoutExpired:
        print(f'{aid} {acct["email"]}: FAIL (timeout after 240s)')
        failures += 1
        try:
            os.remove(outfile.name)
        except OSError:
            pass
        continue
    dt = time.time() - t0
    try:
        last = open(outfile.name, errors='replace').read().strip()
    except OSError:
        last = ''
    try:
        os.remove(outfile.name)
    except OSError:
        pass
    out = (r.stdout or '').strip()
    if r.returncode == 0 and ('ok' in last.lower() or 'ok' in out.lower()):
        # A real call succeeded: this account is definitively alive.
        try:
            os.remove(os.path.join(d, '.expired'))
        except OSError:
            pass
        print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {(last or 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 Codex 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, a workspace admin
            # has simply turned Codex access off. Park it — a re-login changes
            # nothing — and say what actually helps.
            mark_expired(d, 'org-blocked',
                         'Codex access is disabled for the account by a workspace admin')
            hint = (f' — ORG BLOCKED, excluded from the pool; '
                    f'try: codex-accounts relogin {aid}')
        elif AUTH_ERR.search(out) or AUTH_ERR.search(err):
            mark_expired(d, 'auth-error', 'a real call came back not-authenticated')
            hint = f' — login is dead, run: codex-accounts relogin {aid}'
        print(f'{aid} {acct["email"]}: FAIL rc={r.returncode} '
              f'out={(last or 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.
# NB: auth.json is deliberately NEVER pushed (in either direction): the refresh
# token inside it rotates, and two machines refreshing one grant invalidate each
# other. A codex account that should run on a target machine is signed in THERE
# (codex-accounts add / login — device-code flow). Sync aligns the account LIST,
# seeds, and advisory limit state.
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"
    local seed
    for seed in config.toml; 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's account dirs must be an explicit manual act, never 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.
  if [ -f "$ACC_ROOT/codex-settings-policy.json" ]; then
    local subagent_cap
    subagent_cap="$("$PYBIN" "$LIB_DIR/codex_settings.py" policy-value "$ACC_ROOT")" \
      || fail "cannot read opted-in Codex settings policy"
    ssh -o BatchMode=yes "$server" \
      "CODEX_ACCOUNTS_ROOT='$sroot' '$srepo/bin/codex-accounts' configure --max-subagents '$subagent_cap' --json" \
      >>"$ACC_ROOT/sync.log" 2>&1 || fail "Codex settings policy was not verified on $server"
  fi
  ssh -o BatchMode=yes "$server" "[ -x '$srepo/bin/codex-accounts' ] && '$srepo/bin/codex-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: codex-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 'codex-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 account dirs. 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) — the codex twin of
# claude-accounts' cmd_mcp. `add`/`add-json`/`remove` edit this pool's registry under
# the mutate lock, reconcile every account's config.toml, auto-sync, and by default
# (--provider both) make the same change in the claude pool. `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 claude-only Mac must not warn on every codex
    # change. An EXPLICIT --provider claude 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 [ "${CODEX_MULTIACC_NO_SYNC:-0}" = "1" ]; then export CLAUDE_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
}

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 codex.
  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 npm_bin after
      # Same resolution and same prefix discipline as claude-accounts: a launchd/cron
      # PATH has no npm, and the npm that must run is the one owning the RUNNING copy.
      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"
      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
        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 codex runs refresh any expired tokens, so the
  # limits pass that follows always has fresh bearers.
  out="$( { echo "== verify =="; cmd_verify; echo; echo "== limits =="; cmd_limits; } 2>&1 )" || rc=1
  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 "codex-multiacc health check FAILED — run codex-accounts status" with title "claude-multiacc"' 2>/dev/null || true
  fi
  return $rc
}

cmd_init_pool() {
  # Called by install.sh: create the codex pool skeleton + manifest (idempotent).
  # An explicit server override updates an existing manifest too (same behavior
  # install.sh applies to the claude manifest).
  manifest_init "${1:-}"
  if [ -n "${1:-}" ]; then
    "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF'
import json, os, sys
doc = json.load(open(sys.argv[1]))
doc['server'] = sys.argv[2]
with open(sys.argv[1] + '.tmp', 'w') as f:
    json.dump(doc, f, indent=2)
    f.write('\n')
os.replace(sys.argv[1] + '.tmp', sys.argv[1])
PYEOF
  fi
  echo "codex account pool: $ACC_ROOT (manifest ready)"
}

# 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 login expired relogin re-login sync configure verify limits post-sync health self-update init-pool 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 "$@" ;;
  login) shift; cmd_login "$@" ;;
  expired) shift; cmd_expired "$@" ;;
  relogin|re-login) shift; cmd_relogin "$@" ;;
  sync) shift; cmd_sync "$@" ;;
  configure) shift; cmd_configure "$@" ;;
  verify) shift; cmd_verify "$@" ;;
  limits) shift; cmd_limits "$@" ;;
  post-sync) shift; cmd_post_sync "$@" ;;
  health) shift; cmd_health "$@" ;;
  self-update) shift; cmd_self_update "$@" ;;
  init-pool) shift; cmd_init_pool "$@" ;;
  mcp) shift; cmd_mcp "$@" ;;
  help|--help|-h) usage ;;
  *) usage; exit 1 ;;
esac
