#!/usr/bin/env bash
# lib/common.sh — claude-multiacc shared helpers.
# Sourced by bin/claude-accounts, bin/codex-accounts and install.sh.
# Bash 3.2 compatible (macOS stock).
#
# PROVIDER SUPPORT: the same pool machinery serves two independent providers —
# claude (Claude Code, the default) and codex (OpenAI Codex CLI). A caller opts
# into codex by exporting MULTIACC_PROVIDER=codex BEFORE sourcing this file; every
# path/endpoint global below then points at the codex pool. The two pools are
# completely separate on disk (~/.claude-accounts vs ~/.codex-accounts), so
# nothing an operation does on one provider can ever touch the other's accounts.
# shellcheck disable=SC2034

MULTIACC_PROVIDER="${MULTIACC_PROVIDER:-claude}"
PYBIN="${CLAUDE_MULTIACC_PYTHON:-python3}"
DEFAULT_SERVER="root@138.197.36.107"
DEFAULT_SERVER_REPO="/root/claude-multiacc"

LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)"

# INSTANCE-SCOPED POOLS: <PROVIDER>_ACCOUNTS_ROOT relocates the whole pool, so
# several app-robot instances on one machine/user keep completely isolated account
# sets. The older <PROVIDER>_ACCOUNTS_DIR spelling still works (it is what the test
# suite and existing runner envs set) — _ROOT wins when both are present.
if [ "$MULTIACC_PROVIDER" = "codex" ]; then
  ACC_ROOT="${CODEX_ACCOUNTS_ROOT:-${CODEX_ACCOUNTS_DIR:-$HOME/.codex-accounts}}"
  DEFAULT_ACC_ROOT="$HOME/.codex-accounts"
  DEFAULT_SERVER_ROOT="/root/.codex-accounts"
  USAGE_URL="${CODEX_MULTIACC_USAGE_URL:-https://chatgpt.com/backend-api/codex/usage}"
  AUDIT_PY="$LIB_DIR/codex_audit.py"
  PROVIDER_CLI="codex-accounts"
  SYNC_TARGET_ENV="${CODEX_MULTIACC_SYNC_TARGET:-${MULTIACC_SYNC_TARGET:-}}"
  SYNC_ROOT_ENV="${CODEX_MULTIACC_SYNC_ROOT:-${MULTIACC_SYNC_ROOT:-}}"
  SYNC_REPO_ENV="${CODEX_MULTIACC_SYNC_REPO:-${MULTIACC_SYNC_REPO:-}}"
else
  ACC_ROOT="${CLAUDE_ACCOUNTS_ROOT:-${CLAUDE_ACCOUNTS_DIR:-$HOME/.claude-accounts}}"
  DEFAULT_ACC_ROOT="$HOME/.claude-accounts"
  DEFAULT_SERVER_ROOT="/root/.claude-accounts"
  USAGE_URL="${CLAUDE_MULTIACC_USAGE_URL:-https://api.anthropic.com/api/oauth/usage}"
  AUDIT_PY="$LIB_DIR/audit.py"
  PROVIDER_CLI="claude-accounts"
  SYNC_TARGET_ENV="${CLAUDE_MULTIACC_SYNC_TARGET:-${MULTIACC_SYNC_TARGET:-}}"
  SYNC_ROOT_ENV="${CLAUDE_MULTIACC_SYNC_ROOT:-${MULTIACC_SYNC_ROOT:-}}"
  SYNC_REPO_ENV="${CLAUDE_MULTIACC_SYNC_REPO:-${MULTIACC_SYNC_REPO:-}}"
fi
MANIFEST="$ACC_ROOT/accounts.json"
REPORT_PY="$LIB_DIR/report.py"

ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
epoch_now() { date +%s; }

log_to() { # log_to <file-in-acc-root> <msg...>
  local f="$1"; shift
  [ -d "$ACC_ROOT" ] || return 0
  printf '%s %s\n' "$(ts_utc)" "$*" >> "$ACC_ROOT/$f" 2>/dev/null || true
}

die() { printf '%s: error: %s\n' "$PROVIDER_CLI" "$*" >&2; exit 1; }
warn() { printf '%s: warning: %s\n' "$PROVIDER_CLI" "$*" >&2; }

machine_kind() { case "$(uname -s)" in Darwin) echo mac ;; *) echo linux ;; esac; }

if [ "$(uname -s)" = "Darwin" ]; then
  file_mtime() { stat -f %m "$1" 2>/dev/null || echo 0; }
else
  file_mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
fi

canon_path() { # resolve symlinks (loop-guarded) and normalize; always prints something
  local p="$1" t i=0 d b
  case "$p" in /*) ;; *) p="$PWD/$p" ;; esac
  while [ -L "$p" ] && [ "$i" -lt 40 ]; do
    t="$(readlink "$p")" || break
    case "$t" in /*) p="$t" ;; *) p="$(dirname "$p")/$t" ;; esac
    i=$((i+1))
  done
  d="$(cd "$(dirname "$p")" 2>/dev/null && pwd -P)" || { printf '%s\n' "$p"; return 0; }
  b="$(basename "$p")"
  if [ "$d" = "/" ]; then printf '/%s\n' "$b"; else printf '%s/%s\n' "$d" "$b"; fi
}

# Matches both shims: bin/claude ("claude-multiacc-shim") and bin/codex
# ("codex-multiacc-shim") — find_real_bin must skip either, whatever it scans for.
is_shim_file() { head -c 300 "$1" 2>/dev/null | grep -q multiacc-shim; }

# Prints an INVOCABLE path to the real <name> binary (kept as the symlink path,
# not the canonical target, so process cmdlines keep matching monitors like
# `pgrep -f '.local/bin/claude'`). $1 = binary name, $2 = caller path to exclude.
find_real_bin() {
  local name="$1" self_c cand c d
  self_c="$(canon_path "${2:-${BASH_SOURCE[0]}}")"
  local oldifs="$IFS"
  IFS=':'; set -f
  # shellcheck disable=SC2086
  set -- $PATH
  IFS="$oldifs"; set +f
  for d in "$@"; do
    [ -n "$d" ] || continue
    cand="$d/$name"
    [ -f "$cand" ] && [ -x "$cand" ] || continue
    c="$(canon_path "$cand")"
    [ "$c" = "$self_c" ] && continue
    case "$c" in "$ACC_ROOT"/*) continue ;; esac
    is_shim_file "$c" && continue
    printf '%s\n' "$cand"; return 0
  done
  for cand in "$HOME/.local/bin/$name" "/usr/local/bin/$name" "/opt/homebrew/bin/$name" "/usr/bin/$name"; do
    [ -f "$cand" ] && [ -x "$cand" ] || continue
    c="$(canon_path "$cand")"
    [ "$c" = "$self_c" ] && continue
    is_shim_file "$c" && continue
    printf '%s\n' "$cand"; return 0
  done
  return 1
}

find_real_claude() { find_real_bin claude "${1:-}"; }
find_real_codex() { find_real_bin codex "${1:-}"; }

manifest_get() { # manifest_get <dot.path> [default]
  local val
  if [ -f "$MANIFEST" ]; then
    val="$("$PYBIN" - "$MANIFEST" "$1" <<'PYEOF' 2>/dev/null
import json, sys
cur = json.load(open(sys.argv[1]))
for part in sys.argv[2].split('.'):
    if isinstance(cur, dict) and part in cur:
        cur = cur[part]
    else:
        sys.exit(1)
print(cur if not isinstance(cur, (dict, list)) else json.dumps(cur))
PYEOF
)" && { printf '%s\n' "$val"; return 0; }
  fi
  [ $# -ge 2 ] && { printf '%s\n' "$2"; return 0; }
  return 1
}

# Account ids are used to build filesystem paths and remote shell words, so they are
# validated at EVERY boundary — a hand-edited or corrupted manifest must never be able
# to smuggle `../` (path traversal) or shell metacharacters out of the pool directory.
valid_acct_id() { case "$1" in acct-[0-9][0-9]) return 0 ;; *) return 1 ;; esac; }

account_ids() { # prints VALID manifest account ids, one per line
  [ -f "$MANIFEST" ] || return 0
  "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null
import json, re, sys
for a in json.load(open(sys.argv[1])).get('accounts', []):
    if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))):
        print(a['id'])
PYEOF
}

# Remote paths and ssh targets come from the manifest and are interpolated into a
# remote shell command, so they must be strictly shaped — no quotes, spaces, $, ;, etc.
# Reject-by-character-class (a trailing `*` glob would match metacharacters and defeat
# the check), then require the leading shape.
valid_remote_path() {
  case "$1" in
    *[!A-Za-z0-9._/-]*) return 1 ;;   # any char outside the safe set => reject
    /?*) return 0 ;;                  # must be an absolute path
    *) return 1 ;;
  esac
}
valid_ssh_target() {
  case "$1" in
    *[!A-Za-z0-9._@-]*) return 1 ;;
    ?*) return 0 ;;
    *) return 1 ;;
  esac
}

acct_dir() { printf '%s/%s\n' "$ACC_ROOT" "$1"; }

# ---- sync target resolution ----------------------------------------------------
# Where this pool pushes, in precedence order:
#   1. <PROVIDER>_MULTIACC_SYNC_TARGET / MULTIACC_SYNC_TARGET (env — the runner daemon
#      sets these per instance, without rewriting a manifest it does not own)
#   2. the manifest's server/server_root/server_repo (what install.sh --server wrote)
#   3. DEFAULT_SERVER — the historical default, unchanged for existing installs.
# The value 'none' (also 'local'/'off'/'disabled'/empty) selects LOCAL-ONLY mode: the
# pool has no ssh target because something else (the panel/runner daemon) distributes
# it. That is a supported mode, not a misconfiguration.
sync_target_is_local() {
  case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in
    ''|none|local|off|disabled) return 0 ;;
    *) return 1 ;;
  esac
}
sync_target() {
  if [ -n "${SYNC_TARGET_ENV:-}" ]; then printf '%s\n' "$SYNC_TARGET_ENV"
  else manifest_get server "$DEFAULT_SERVER"; fi
}
sync_target_root() {
  if [ -n "${SYNC_ROOT_ENV:-}" ]; then printf '%s\n' "$SYNC_ROOT_ENV"
  else manifest_get server_root "$DEFAULT_SERVER_ROOT"; fi
}
sync_target_repo() {
  if [ -n "${SYNC_REPO_ENV:-}" ]; then printf '%s\n' "$SYNC_REPO_ENV"
  else manifest_get server_repo "$DEFAULT_SERVER_REPO"; fi
}

# The machine-readable pool document shared by `list|status|limits --json`
# (lib/report.py). Stdout is JSON and nothing else — every human line a --json run
# would otherwise print goes to stderr or a log.
emit_report_json() { # emit_report_json <list|status|limits>
  local target mode
  target="$(sync_target)"
  if sync_target_is_local "$target"; then mode=local; target=""; else mode=server; fi
  MULTIACC_REPORT_SYNC_TARGET="$target" \
  MULTIACC_REPORT_SYNC_ROOT="$(sync_target_root)" \
  MULTIACC_REPORT_SYNC_REPO="$(sync_target_repo)" \
  MULTIACC_REPORT_SYNC_MODE="$mode" \
  "$PYBIN" "$REPORT_PY" "$ACC_ROOT" "$MULTIACC_PROVIDER" "$(machine_kind)" "${1:-list}" \
    || die "could not build the JSON report from $ACC_ROOT"
}

# True when every manifest account has a well-formed id and an email. A corrupt
# manifest must never be pushed (it would blank the target pools) and must never be
# reported as a clean local sync either.
manifest_well_formed() {
  "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null
import json, re, sys
doc = json.load(open(sys.argv[1]))
accounts = doc.get('accounts')
if not isinstance(accounts, list):
    sys.exit(1)
for a in accounts:
    if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
       or not str(a.get('email', '')).strip():
        sys.exit(1)
PYEOF
}

# The housekeeping a pool does on ITSELF: seed any missing account dirs and put the
# credential files back to 0600. It is what a sync target runs after a push, and all
# a local-only pool has to do (nothing is pushed anywhere there).
local_pool_fixup() {
  local id d
  for id in $(account_ids); do
    d="$ACC_ROOT/$id"
    seed_account_dir "$d"
    if [ "$MULTIACC_PROVIDER" = "codex" ]; then
      [ -f "$d/auth.json" ] && chmod 600 "$d/auth.json" 2>/dev/null
    else
      [ -f "$d/server.token" ] && chmod 600 "$d/server.token" 2>/dev/null
      [ -f "$d/.credentials.json" ] && chmod 600 "$d/.credentials.json" 2>/dev/null
    fi
  done
  return 0
}

CREDENTIAL_PY="$LIB_DIR/credential.py"

# EXIT-trap body for the limits refresh lock — a function for the same reason as
# import_cred_cleanup: the lock path contains the pool root.
LIMITS_LOCK=""
limits_lock_release() {
  [ -n "${LIMITS_LOCK:-}" ] && rm -rf "$LIMITS_LOCK" 2>/dev/null
  LIMITS_LOCK=""
  return 0
}

# EXIT-trap body for cmd_import_credential. A function, not an interpolated string:
# the staged blob lives under the pool root, and a root containing a quote would
# otherwise inject shell code into the trap program.
IMPORT_STAGE=""
import_cred_cleanup() {
  [ -n "${IMPORT_STAGE:-}" ] && rm -f "$IMPORT_STAGE" 2>/dev/null
  IMPORT_STAGE=""
  mutate_unlock 2>/dev/null || true
  return 0
}

cmd_export_credential() {
  # Emit a self-contained transfer blob for ONE account. Read-only on this pool:
  # nothing is written, refreshed or re-minted, so exporting a live production
  # account cannot disturb it. lib/credential.py owns the format AND the
  # portability rule (it exits 3 on a machine-local credential); this wrapper only
  # decides where the blob lands and propagates that exit code verbatim, because a
  # daemon branches on it.
  require_manifest
  local id="" out="" identity=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --out) out="${2:?--out requires a path}"; shift 2 ;;
      --identity-only) identity=1; shift ;;
      --*) die "unknown option: $1" ;;
      *) if [ -z "$id" ]; then id="$1"; shift; else die "unexpected argument: $1"; fi ;;
    esac
  done
  [ -n "$id" ] || die "usage: $PROVIDER_CLI export-credential <acct-NN> [--out PATH] [--identity-only]"
  valid_acct_id "$id" || die "not a valid account id: $id"
  set -- export "$ACC_ROOT" "$MULTIACC_PROVIDER" "$id"
  [ "$identity" = "1" ] && set -- "$@" --identity-only
  # --out is handled INSIDE credential.py: it creates a unique 0600 file next to the
  # target and renames it into place, so the blob is never briefly world-readable and
  # a planted symlink at the target is replaced rather than written through — neither
  # of which a shell redirect can promise.
  [ -n "$out" ] && set -- "$@" --out "$out"
  local rc=0
  "$PYBIN" "$CREDENTIAL_PY" "$@" || rc=$?
  [ "$rc" = "0" ] || return "$rc"
  log_to ops.log "export-credential $id -> ${out:-stdout} identity_only=$identity"
  if [ -n "$out" ]; then
    if [ "$identity" = "1" ]; then
      echo "Wrote $out (identity only — no credential material)."
    else
      echo "Wrote $out (carries a live credential: keep it secret, delete it once imported)."
    fi
  fi
  return 0
}

cmd_import_credential() {
  # Install a transfer blob into this pool, non-interactively. Idempotent by EMAIL:
  # re-importing an account that is already here refreshes its credential in place
  # instead of creating a second entry, so a daemon can push the same pool to a
  # machine repeatedly. The secret never passes through a shell variable or argv —
  # it is staged 0600 and installed by lib/credential.py.
  require_manifest
  local id="" in_file="-" home="" no_sync=0 force=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --in|--file) in_file="${2:?--in requires a path}"; shift 2 ;;
      --id) id="${2:?--id requires acct-NN}"; shift 2 ;;
      --home) home="${2:?--home requires mac|server}"; shift 2 ;;
      --no-sync) no_sync=1; shift ;;
      --force) force=1; shift ;;
      --*) die "unknown option: $1" ;;
      *) if [ -z "$id" ]; then id="$1"; shift; else die "unexpected argument: $1"; fi ;;
    esac
  done
  if [ -n "$id" ]; then valid_acct_id "$id" || die "not a valid account id: $id"; fi
  if [ "$in_file" = "-" ] && [ -t 0 ]; then
    die "import-credential reads the blob on stdin — pipe it in, or pass --in PATH"
  fi
  mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
  # mktemp (0600, O_EXCL, unpredictable name), not "$$": the staged file holds live
  # credential material, and a predictable path could be pre-planted as a symlink and
  # written through. The cleanup trap is armed BEFORE anything is written, so an
  # interrupt during staging cannot leave the blob behind either.
  local stage rc=0
  stage="$(mktemp "$ACC_ROOT/tmp/import-cred.XXXXXX")" \
    || die "could not create a staging file under $ACC_ROOT/tmp"
  IMPORT_STAGE="$stage"
  trap import_cred_cleanup EXIT
  if [ "$in_file" = "-" ]; then
    cat > "$stage" || rc=$?
  else
    [ -f "$in_file" ] || { import_cred_cleanup; trap - EXIT; die "credential blob not found: $in_file"; }
    cat "$in_file" > "$stage" || rc=$?
  fi
  [ "$rc" = "0" ] || { import_cred_cleanup; trap - EXIT; die "could not read the credential blob"; }
  chmod 600 "$stage" 2>/dev/null || true

  local meta cclass bid bemail bhome badded btype us
  # 0x1F, not a tab: bash collapses runs of IFS *whitespace*, so a blob with an empty
  # `home` would shift added_at into it and corrupt the manifest entry. credential.py
  # refuses any control character in the metadata, so the separator is unambiguous.
  us="$(printf '\037')"
  meta="$("$PYBIN" "$CREDENTIAL_PY" inspect "$MULTIACC_PROVIDER" "$stage")" || rc=$?
  if [ "$rc" != "0" ]; then
    import_cred_cleanup
    trap - EXIT
    return "$rc"
  fi
  IFS="$us" read -r cclass bid bemail bhome badded btype <<EOF
$meta
EOF
  [ -n "$home" ] || home="$bhome"
  [ -n "$home" ] || home="$(machine_kind)"

  mutate_lock || { import_cred_cleanup; trap - EXIT; die "could not acquire the account lock (another op is stuck?) — try again"; }
  fail_locked() { import_cred_cleanup; trap - EXIT; die "$@"; }
  local owner target_id existing
  owner="$(email_owner "$bemail")"
  target_id="$id"
  if [ -z "$target_id" ]; then
    if [ -n "$owner" ]; then
      target_id="$owner"          # this email already lives here: refresh it in place
    elif [ -n "$bid" ] && [ ! -e "$ACC_ROOT/$bid" ] && ! account_ids | grep -qx "$bid"; then
      target_id="$bid"            # the source's id is free here: keep ids aligned
    else
      target_id="$(next_id)"
    fi
  fi
  if [ -n "$owner" ] && [ "$owner" != "$target_id" ] && [ "$force" != "1" ]; then
    fail_locked "$bemail is already registered as $owner — import into it ('$PROVIDER_CLI import-credential $owner') or pass --force"
  fi
  existing="$(manifest_email_of "$target_id")"
  if [ -n "$existing" ] && [ "$existing" != "$bemail" ] && [ "$force" != "1" ]; then
    fail_locked "$target_id is $existing on this machine, not $bemail — pick another id or pass --force"
  fi
  # An adopted account dir is a symlink (usually to ~/.claude). Writing a credential
  # through it would land outside the pool — the very boundary account ids are
  # validated to protect — so this is refused outright, --force included: --force
  # settles identity conflicts, it does not authorize writing outside the pool.
  if [ -L "$ACC_ROOT/$target_id" ]; then
    fail_locked "$target_id is adopted (a symlink to $(readlink "$ACC_ROOT/$target_id")) — a credential must never be written through it; remove the account first ('$PROVIDER_CLI remove $target_id'), or import under a different id"
  fi
  local d="$ACC_ROOT/$target_id"
  seed_account_dir "$d"
  "$PYBIN" "$CREDENTIAL_PY" install "$MULTIACC_PROVIDER" "$stage" "$d" >/dev/null \
    || fail_locked "could not install the credential into $d — nothing registered"
  manifest_add_account "$target_id" "$bemail" "$home" "$badded" \
    || fail_locked "manifest update failed — $target_id was seeded but NOT registered (re-run import-credential)"
  # Fresh material landed: whatever parked this account before no longer applies.
  [ "$cclass" = "portable" ] && clear_auth_markers "$d"
  import_cred_cleanup
  trap - EXIT
  log_to ops.log "import-credential $target_id $bemail class=$cclass type=$btype home=$home"
  if [ "$cclass" = "portable" ]; then
    echo "Imported $target_id ($bemail, home=$home) with a portable $btype credential."
  else
    echo "Registered $target_id ($bemail, home=$home) — identity only, no credential."
    echo "It needs one interactive sign-in on a machine that will run it: $PROVIDER_CLI login $target_id"
  fi
  [ "$no_sync" = "1" ] || auto_sync
}

has_local_auth() { # $1 = acct dir
  if [ "$MULTIACC_PROVIDER" = "codex" ]; then
    [ -s "$1/auth.json" ]
  else
    [ -f "$1/.credentials.json" ] || [ -s "$1/server.token" ]
  fi
}

# ---- expired-login bookkeeping -------------------------------------------------
# `.expired` is the persistent "this account cannot authenticate" marker the shim
# honors (bin/claude: expired_marked). Two lines: marked-at epoch, then details.
# It is deliberately NOT time-boxed like `.limited`: dead auth only heals through a
# re-login, a credential refresh, or a usage fetch that proves the bearer works.
mark_expired() { # mark_expired <acct dir> <reason-slug> [detail]
  local d="$1" slug="$2" detail="${3:-}"
  [ -d "$d" ] || return 0
  {
    epoch_now
    printf 'reason=%s marked_at=%s detail=%s\n' "$slug" "$(ts_utc)" "$detail"
  } > "$d/.expired.$$" 2>/dev/null \
    && mv -f "$d/.expired.$$" "$d/.expired" 2>/dev/null \
    || rm -f "$d/.expired.$$" 2>/dev/null || true
}

# Called wherever fresh auth lands (login/add/mint, successful usage fetch): the
# account is provably alive again, so both the dead-auth marker and any refresh
# backoff must go, or it would stay parked until the next re-login.
clear_auth_markers() { # $1 = acct dir
  rm -f "$1/.expired" "$1/.oauth-refresh.json" 2>/dev/null || true
}

# TSV rows: id, email, home, state (ok|expired|blocked|missing|remote), label, reason, fix.
# Fails LOUD (nonzero, message on stderr): callers decide policy from these rows, and a
# silently empty audit reads exactly like a perfectly healthy pool.
account_audit() {
  local out rc err="$ACC_ROOT/tmp/audit.$$.err"
  mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
  # stderr goes to a file, never into the rows — a stray warning must not become a
  # bogus account line.
  out="$("$PYBIN" "$AUDIT_PY" "$ACC_ROOT" "$(machine_kind)" 2>"$err")"
  rc=$?
  if [ "$rc" -ne 0 ]; then
    printf '%s: cannot audit accounts (%s)\n' "$PROVIDER_CLI" "$(tail -1 "$err" 2>/dev/null)" >&2
    rm -f "$err" 2>/dev/null
    return 1
  fi
  rm -f "$err" 2>/dev/null
  [ -n "$out" ] && printf '%s\n' "$out"
  return 0
}

# Ids the shim will NOT select — everything a human has to look at.
accounts_unusable() {
  account_audit \
    | awk -F'\t' 'NF >= 4 && $1 != "" && ($4 == "expired" || $4 == "blocked" || $4 == "missing") { print $1 }'
}

# Ids a re-login should be pointed at. Org-blocked accounts are included: a fresh
# sign-in re-issues the grant and in practice clears the block, so they are handled
# exactly like any other dead login rather than being left for someone to notice.
accounts_needing_login() {
  account_audit \
    | awk -F'\t' 'NF >= 4 && $1 != "" && ($4 == "expired" || $4 == "blocked" || $4 == "missing") { print $1 }'
}

# True when <acct dir>'s credential can authenticate right now (the audit module's
# rule — audit.py for claude, codex_audit.py for codex; both expose creds_state).
creds_alive() { # $1 = acct dir
  local cred
  if [ "$MULTIACC_PROVIDER" = "codex" ]; then cred="$1/auth.json"; else cred="$1/.credentials.json"; fi
  [ -s "$cred" ] || return 1
  "$PYBIN" - "$AUDIT_PY" "$cred" <<'PYEOF' 2>/dev/null
import importlib.util, sys, time
spec = importlib.util.spec_from_file_location('audit', sys.argv[1])
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
sys.exit(0 if mod.creds_state(sys.argv[2], time.time())[0] == 'ok' else 1)
PYEOF
}

manifest_init() { # manifest_init [server-target]
  mkdir -p "$ACC_ROOT/tmp"
  chmod 700 "$ACC_ROOT"
  [ -f "$MANIFEST" ] && return 0
  "$PYBIN" - "$MANIFEST" "${1:-$DEFAULT_SERVER}" "$DEFAULT_SERVER_ROOT" "$DEFAULT_SERVER_REPO" <<'PYEOF'
import json, sys
doc = {
    "version": 1,
    "server": sys.argv[2],
    "server_root": sys.argv[3],
    "server_repo": sys.argv[4],
    "threshold": 90,
    "accounts": [],
}
import os
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
}

# Seed an account config dir so headless runs never prompt.
#   claude: stripped .claude.json, copied settings.json, shared projects/ symlink
#           (shared history => --continue/--resume work regardless of picked account).
#   codex:  copied config.toml (carries project trust + settings, no identity),
#           shared sessions/ symlink (=> `codex resume` finds every session), and a
#           shared AGENTS.md symlink so global instructions apply under any account.
seed_account_dir() { # $1 = acct dir
  local d="$1"
  mkdir -p "$d"
  chmod 700 "$d"
  if [ "$MULTIACC_PROVIDER" = "codex" ]; then
    if [ ! -f "$d/config.toml" ] && [ -f "$HOME/.codex/config.toml" ]; then
      cp "$HOME/.codex/config.toml" "$d/config.toml" 2>/dev/null || true
    fi
    if [ ! -e "$d/sessions" ] && [ -d "$HOME/.codex/sessions" ]; then
      ln -s "$HOME/.codex/sessions" "$d/sessions" 2>/dev/null || true
    fi
    if [ ! -e "$d/AGENTS.md" ] && [ -f "$HOME/.codex/AGENTS.md" ]; then
      ln -s "$HOME/.codex/AGENTS.md" "$d/AGENTS.md" 2>/dev/null || true
    fi
    return 0
  fi
  if [ ! -f "$d/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
    "$PYBIN" - "$HOME/.claude.json" "$d/.claude.json" <<'PYEOF' 2>/dev/null || true
import json, sys
try:
    doc = json.load(open(sys.argv[1]))
except Exception:
    sys.exit(0)
for k in ('oauthAccount', 'userID'):
    doc.pop(k, None)
import os
with open(sys.argv[2] + '.tmp', 'w') as f:
    json.dump(doc, f)
os.replace(sys.argv[2] + '.tmp', sys.argv[2])
PYEOF
  fi
  if [ ! -f "$d/settings.json" ] && [ -f "$HOME/.claude/settings.json" ]; then
    cp "$HOME/.claude/settings.json" "$d/settings.json" 2>/dev/null || true
  fi
  if [ ! -e "$d/projects" ] && [ -d "$HOME/.claude/projects" ]; then
    ln -s "$HOME/.claude/projects" "$d/projects" 2>/dev/null || true
  fi
}

# Short-lived mutation lock: serialize only the brief critical sections (id reservation,
# manifest write), NOT long interactive work like a browser sign-in — so several
# `add`/`login` in different terminals run in parallel and only briefly wait on each other.
# Held for milliseconds; a lock older than 30s is assumed abandoned (holder died) and reclaimed.
MUTATE_LOCK_HELD=0
mutate_lock() {
  local lock="$ACC_ROOT/.locks/mutate" tries=0
  mkdir -p "$ACC_ROOT/.locks" 2>/dev/null
  while ! mkdir "$lock" 2>/dev/null; do
    if [ $(( $(epoch_now) - $(file_mtime "$lock") )) -gt 30 ]; then
      rm -rf "$lock" 2>/dev/null
      continue
    fi
    tries=$((tries + 1))
    [ "$tries" -gt 600 ] && return 1   # ~60s ceiling; contention should clear in ms
    sleep 0.1
  done
  MUTATE_LOCK_HELD=1
  return 0
}
# Only removes the lock dir when THIS process holds it — so a normal release, or a
# cleanup trap firing after release, can never delete a lock a parallel process just took.
mutate_unlock() {
  [ "${MUTATE_LOCK_HELD:-0}" = 1 ] || return 0
  rm -rf "$ACC_ROOT/.locks/mutate" 2>/dev/null
  MUTATE_LOCK_HELD=0
}

rotate_log() { # keep logs bounded: rotate_log <file-in-acc-root> (keeps last ~256KB)
  local f="$ACC_ROOT/$1"
  [ -f "$f" ] || return 0
  local sz
  if [ "$(uname -s)" = "Darwin" ]; then sz="$(stat -f %z "$f" 2>/dev/null || echo 0)"
  else sz="$(stat -c %s "$f" 2>/dev/null || echo 0)"; fi
  if [ "${sz:-0}" -gt 524288 ]; then
    tail -c 262144 "$f" > "$f.tmp" 2>/dev/null && mv "$f.tmp" "$f"
  fi
}
