#!/usr/bin/env bash
# swap-guard — shared state/guard engine for the /swap + /handoff Claude Code integration.
# Contract: section A of the binding interface contract.
# Subcommands:
#   whoami                          own session registry JSON (compact); exit 1 if not in a session
#   sessions                        JSON array of OTHER live claude sessions (always exit 0)
#   preflight <target>              switch-safety verdict JSON for slot number / alias / email (always exit 0)
#   path flag|handoff|codex-handoff|remote-handoff [--cwd <dir>]
#                                   print per-cwd state-file path (codex-handoff: overcodex's pending file)
#   flag <json>                     validate + fill + atomic-write relaunch flag; prints path (exit 1 on bad input)
#   status                          own-session status JSON (always exit 0)
#   cancel [--cwd <dir>]            remove pending handoff + flag for cwd (always exit 0)
#   history [--cwd <dir>] [--all]   archived handoff packages (newest first, numbered) as JSON
#   restore [<n>|<path>] [--cwd <dir>] [--force]
#                                   re-arm an archived package as this cwd's pending handoff
#   auto tick|install|uninstall|status [opts]
#                                   busy-aware wrapper around `cswap auto --once` (+ opt-in LaunchAgent)
#   keepalive tick|on|off|status    keep idle accounts' logins alive (LaunchAgent, on by default)
#   logins [--refresh|--cached] [--text]  when each account's login ends (keychain dates only)
#   forecast [--text]               when you run out at this pace, and which account to use first
#   prewarm on|off|status|tick      start idle accounts' 5-hour windows early (opt-in; long-lived tokens)
#   token add|list|rm|run           one-year setup-tokens for headless jobs (keychain; never shown)
#   fleet add|rm|list|status|swap|auto  other Macs over Tailscale SSH: sessions, accounts, logins
#   remote start|list|logs|stop|attach  Remote Control sessions on another Mac (claude --bg)
#   artifacts [--session <id>] [--cwd <dir>]
#                                   collect this session's claude.ai artifacts: copy sources to
#                                   $STATE_ROOT/handoff-artifacts/<sid>/ + manifest.json; prints manifest
#   artifacts find|list [words] [--account X] | link <old> <new> | index | carry
#                                   the artifact registry (every publish, with its owning account)
#   artifact-log                    PostToolUse hook (Artifact): append one publish to the registry
#   ratelimit [--dry-run]           StopFailure hook (rate_limit): instant swap on a usage cap
#   server-retry on|off|status|--hook  StopFailure hook (server_error|overloaded): capped auto-resume, opt-in
#   loop-guard on|off|status|--hook    PostToolUseFailure hook: same call failing the same way, opt-in
#   checkpoint on|off|status|--hook    PostToolUse hook: wrap up once when the account nears its limit
#   budget [--hook]                 usage bands for ULTRACODE budget pressure (UserPromptSubmit hook)
#   mem [--text] | clean | watch | autoclean | nudge | gate | --gate
#                                   this Mac's memory: report, orphan cleanup, watcher, session notes, agent gate
#   browser list | set <account> <profile> | clear <account>  the Chrome profile a re-login opens in
#   login <slot|alias|email> | --new
#                                   safe re-login / add that never overwrites the live account's token
#   schedule-kill <pid>             v1 stub (exit 1)
set -u

STATE_ROOT="$HOME/.claude-swap-backup"
CTX_DIR="$STATE_ROOT/ctx"
ARCHIVE_DIR="$STATE_ROOT/handoff-archive"
ARTIFACTS_DIR="$STATE_ROOT/handoff-artifacts"
SESSIONS_DIR="$HOME/.claude/sessions"

usage() {
  cat >&2 <<'EOF'
usage: swap-guard <subcommand> [args]
  whoami                          own session registry JSON (compact)
  sessions                        JSON array of other live claude sessions
  preflight <target>              switch-safety verdict for slot / alias / email
  path flag|handoff|codex-handoff|remote-handoff [--cwd <dir>]
                                  print per-cwd state-file path (codex-handoff: where overcodex picks it up)
  flag <json>                     write relaunch flag ({"mode":"handoff"|"restart",...}); prints path
  status                          own-session status JSON
  cancel [--cwd <dir>]            remove pending handoff + flag for cwd
  history [--cwd <dir>] [--all]   list archived handoff packages (newest first)
  restore [<n>|<path>] [--cwd <dir>] [--force]
                                  re-arm an archived package (default: newest for cwd)
  auto tick [--threshold PCT] [--model NAMES] [--strategy best|consume-first] [--cooldown SEC] [--no-notify]
                                  one busy-aware auto-switch evaluation (JSON line; exit = cswap's)
  auto install [--interval SEC] [--threshold PCT] [--model NAMES] [--strategy best|consume-first]
               [--cooldown SEC] [--no-notify]
  auto uninstall | auto status    manage the LaunchAgent that runs `auto tick` periodically
  keepalive on [--interval SEC] | off | status | tick
                                  keep every account's login alive while no session runs (every 2 h),
                                  and warn 3 days before a login reaches its end date
  logins [--refresh|--cached] [--text]
                                  when each account's login ends (a new sign-in starts a new one)
  forecast [--text]               when you run out at this pace, and which account to use first
  prewarm on|off|status|tick [--dry-run]
                                  start idle accounts' 5-hour windows early (opt-in; needs token add)
  token add <account> | list | rm <account> | run <account> -- <cmd...>
                                  one-year tokens for headless jobs (claude -p), kept in the keychain
  fleet add <host> | rm <host> | list | status [--text] | swap [--from <email>] [--model M] [--dry-run] | auto on|off
                                  your other Macs (Tailscale SSH): sessions, accounts, dead logins, load;
                                  swap moves them off a capped account
  remote start <mac> [--dir D] [--name N] [goal…] | handoff <mac> [--dir D] [--name N] [--force] | list <mac> | logs|stop|attach <mac> <id|name>
                                  a session on another Mac you drive from claude.ai/code or the app
  artifacts [--session <id>] [--cwd <dir>]
                                  snapshot this session's artifacts (sources + manifest) for handoff
  artifacts find|list [words] [--account X]   search every artifact you have published
  artifacts link <old> <new>      record that <new> is a republished copy of <old>
  artifacts carry [--session <id>]
                                  this session's artifacts owned by another account, with saved sources
  artifacts index                 backfill the registry from past transcripts
  ratelimit [--dry-run] [--print] StopFailure hook: switch accounts the moment a usage cap hits
  auto instant on|off|status      enable/disable that instant swap (default: on)
  server-retry on|off|status      resume a session after a temporary server error (opt-in; max 3 tries/hour)
  loop-guard on|off|status        tell Claude to rethink when one call keeps failing the same way (opt-in)
  checkpoint on|off|status        tell a running session to wrap up once when the account nears its limit (on)
  budget [--hook]                 usage bands the model should know about (hook: inject on change)
  mem [--text] [--footprint]      this Mac's memory: in use, swap, biggest apps, sessions, orphans, suggestions
  mem clean [--dry-run] [--pid N] stop orphaned helper processes (MCP servers, test runners… whose session is gone)
  mem watch on|off|status         the agent that checks memory every minute and acts when it gets tight (on)
  mem autoclean|nudge|gate on|off|status
                                  when tight: clean up orphans / tell every session to hold back /
                                  (critical only) refuse new subagents and workflows — all on by default
  login <slot|alias|email> [--profile P]
                                  re-login a dead account WITHOUT killing the live one's token
                                  (the sign-in page opens in that account's Chrome profile)
  browser list | set <account> <profile> | clear <account>
                                  which Chrome profile a re-login opens in
  login --new                     add a new account the same safe way
  version-check [--force]         newest overclaude on PyPI (cached for a day)
  codex-usage [--refresh]         Codex CLI rate limits per Codex home, from its session logs
  codex-ready                     can /handoff codex work here? (CLI, overcodex hook, limits)
  route-guard [on|off|status]     PreToolUse hook (Agent|Workflow): deny un-routed subagents on Fable
  spend [--days N]                API-price value of your usage (by model/account/session/day) vs plan cost
  codex-review [--dir D] [--model M] [--effort E] [--schema F] [--timeout S] [--raw] <prompt> [--stdin < input]
                                  cross-family second opinion: read-only, ephemeral `codex exec`
  schedule-kill <pid>             phase-2 stub
EOF
  exit 2
}

# HASH(cwd) per shared constants: first 12 hex of sha256
hash_cwd() { printf '%s' "$1" | /usr/bin/shasum -a 256 | awk '{print $1}' | cut -c1-12; }

flag_path_for()    { printf '%s/relaunch-%s.json\n'      "$STATE_ROOT" "$(hash_cwd "$1")"; }
pending_path_for() { printf '%s/handoff-pending-%s.md\n' "$STATE_ROOT" "$(hash_cwd "$1")"; }

# whoami_json [start_pid] — walk the PPID chain from start_pid (default $$) up to pid 1;
# print the first matching registry file's JSON (compact) and return 0, else return 1.
whoami_json() {
  local p="${1:-$$}"
  while :; do
    case "$p" in ''|*[!0-9]*) return 1;; esac
    [ "$p" -gt 1 ] || return 1
    if [ -f "$SESSIONS_DIR/$p.json" ]; then
      jq -c . "$SESSIONS_DIR/$p.json" 2>/dev/null || cat "$SESSIONS_DIR/$p.json"
      return 0
    fi
    p="$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ')"
  done
}

# session_live <pid> — true iff the process exists (kill -0) AND its comm contains "claude"
session_live() {
  local pid="${1:-}" comm
  case "$pid" in ''|*[!0-9]*) return 1;; esac
  kill -0 "$pid" 2>/dev/null || return 1
  comm="$(ps -o comm= -p "$pid" 2>/dev/null)"
  case "$comm" in *claude*) return 0;; *) return 1;; esac
}

# transcript_status <cwd> <sessionId> — activity fallback for sessions whose registry
# file has no status field (clients < ~2.1.211 never publish one). An active session
# appends to ~/.claude/projects/<flattened-cwd>/<sessionId>.jsonl on every turn, so:
# mtime within TRANSCRIPT_ACTIVE_SECS -> "busy"; older -> "idle"; no transcript -> "unknown".
TRANSCRIPT_ACTIVE_SECS=120
transcript_status() {
  local cwd="${1:-}" sid="${2:-}" t mtime now
  [ -n "$cwd" ] && [ -n "$sid" ] || { printf 'unknown\n'; return 0; }
  t="$HOME/.claude/projects/$(printf '%s' "$cwd" | sed 's/[^A-Za-z0-9]/-/g')/$sid.jsonl"
  [ -f "$t" ] || { printf 'unknown\n'; return 0; }
  mtime="$(stat -f %m "$t" 2>/dev/null)"
  case "$mtime" in ''|*[!0-9]*) printf 'unknown\n'; return 0;; esac
  now="$(date +%s)"
  if [ $(( now - mtime )) -le "$TRANSCRIPT_ACTIVE_SECS" ]; then
    printf 'busy\n'
  else
    printf 'idle\n'
  fi
}

# sessions_json — JSON array of OTHER live sessions. status precedence: the value the
# session itself reports (statusSource "reported") > transcript-mtime fallback
# (statusSource "transcript-mtime") > "unknown" (statusSource "none").
sessions_json() {
  local own_pid="" out="[]" new_out f pid entry st
  own_pid="$(whoami_json 2>/dev/null | jq -r '.pid // empty' 2>/dev/null)" || own_pid=""
  for f in "$SESSIONS_DIR"/*.json; do
    [ -f "$f" ] || continue
    pid="$(jq -r '.pid // empty' "$f" 2>/dev/null)" || continue
    [ -n "$pid" ] || continue
    [ "$pid" = "$own_pid" ] && continue
    session_live "$pid" || continue
    entry="$(jq -c '{pid, sessionId, cwd, kind, entrypoint, status: (.status // "unknown"),
      statusSource: (if .status then "reported" else "none" end)}' "$f" 2>/dev/null)" || continue
    [ -n "$entry" ] || continue
    if [ "$(printf '%s' "$entry" | jq -r '.statusSource' 2>/dev/null)" = "none" ]; then
      st="$(transcript_status "$(printf '%s' "$entry" | jq -r '.cwd // empty')" \
                              "$(printf '%s' "$entry" | jq -r '.sessionId // empty')")"
      case "$st" in
        busy|idle) entry="$(printf '%s' "$entry" | jq -c --arg s "$st" '.status = $s | .statusSource = "transcript-mtime"' 2>/dev/null)" || continue;;
      esac
    fi
    new_out="$(printf '%s' "$out" | jq -c --argjson e "$entry" '. + [$e]' 2>/dev/null)" && [ -n "$new_out" ] && out="$new_out"
  done
  printf '%s\n' "$out"
}

# resolve_cwd [explicit] — precedence: explicit --cwd > whoami .cwd > $PWD
resolve_cwd() {
  local c="${1:-}"
  if [ -z "$c" ]; then
    c="$(whoami_json 2>/dev/null | jq -r '.cwd // empty' 2>/dev/null)" || c=""
  fi
  [ -n "$c" ] || c="$PWD"
  printf '%s\n' "$c"
}

# parse_cwd_opt "$@" — echo value of --cwd/--cwd= if present; return 1 if absent
parse_cwd_opt() {
  while [ $# -gt 0 ]; do
    case "$1" in
      --cwd) shift; printf '%s\n' "${1:-}"; return 0;;
      --cwd=*) printf '%s\n' "${1#--cwd=}"; return 0;;
    esac
    shift
  done
  return 1
}

cmd_whoami() {
  local out
  if out="$(whoami_json)"; then
    printf '%s\n' "$out"
    exit 0
  fi
  printf '%s\n' '{"error":"not-in-session"}'
  exit 1
}

cmd_sessions() {
  sessions_json
  exit 0
}

cmd_preflight() {
  local raw="${1:-}" busy busy_n list tgt slot="" verdict detail target_json number email us ends="" left
  busy="$(sessions_json | jq -c '[.[] | select(.status == "busy" or .status == "unknown")]' 2>/dev/null)" || busy="[]"
  [ -n "$busy" ] || busy="[]"
  busy_n="$(printf '%s' "$busy" | jq 'length' 2>/dev/null)" || busy_n=0

  if [ -z "$raw" ]; then
    jq -cn --argjson busy "$busy" \
      '{verdict:"unknown-target",target:{number:null,email:null},busy:$busy,detail:"no target given"}'
    exit 0
  fi

  list="$(cswap list --json 2>/dev/null)"
  if [ -z "$list" ] || ! printf '%s' "$list" | jq -e . >/dev/null 2>&1; then
    jq -cn --argjson busy "$busy" --arg t "$raw" \
      '{verdict:"unknown-target",target:{number:null,email:null},busy:$busy,detail:("cswap list --json failed - cannot verify target " + ($t|tojson))}'
    exit 0
  fi

  # Resolve target: digits -> slot number; else alias in sequence.json; else email
  case "$raw" in
    *[!0-9]*)
      slot="$(jq -r --arg a "$raw" '.accounts | to_entries[] | select((.value.alias // "") == $a) | .key' \
        "$STATE_ROOT/sequence.json" 2>/dev/null | head -n1)" || slot=""
      ;;
    *) slot="$raw";;
  esac
  if [ -n "$slot" ]; then
    tgt="$(printf '%s' "$list" | jq -c --arg n "$slot" '[.accounts[]? | select((.number|tostring) == $n)] | first // empty' 2>/dev/null)"
  else
    tgt="$(printf '%s' "$list" | jq -c --arg e "$raw" '[.accounts[]? | select(((.email // "") | ascii_downcase) == ($e | ascii_downcase))] | first // empty' 2>/dev/null)"
  fi

  if [ -z "$tgt" ]; then
    jq -cn --argjson busy "$busy" --arg t "$raw" \
      '{verdict:"unknown-target",target:{number:null,email:null},busy:$busy,detail:("no account matches " + ($t|tojson) + " (not a slot number, alias, or email)")}'
    exit 0
  fi

  target_json="$(printf '%s' "$tgt" | jq -c '{number, email}')"
  number="$(printf '%s' "$tgt" | jq -r '.number')"
  email="$(printf '%s' "$tgt" | jq -r '.email // ""')"
  us="$(printf '%s' "$tgt" | jq -r '.usageStatus // ""' | tr '[:upper:]' '[:lower:]')"

  case "$us" in
    # Dead = needs a re-login. token_expired / unavailable / keychain_unavailable are
    # transient (cswap retries the refresh itself) and must never trigger one.
    *relogin*|*quarantin*|*invalid*|*revoked*|no_credentials)
      verdict="relogin-required"
      detail="account $number ($email) requires re-login (usageStatus: $us) - recover with: swap-guard login $number (never a plain /login: it kills the live account's token)"
      ;;
    *)
      # A login past (or within hours of) its end date: switching there strands every
      # session on it when the date hits — a new sign-in comes first.
      ends="$(_logins_json 2>/dev/null | jq -r --arg n "$number" 'first(.accounts[]? | select(.slot == $n)) | .endsAt // empty' 2>/dev/null)"
      case "$ends" in ''|*[!0-9]*) ends="";; esac
      # About to refuse on a cached date? Read it again: a renewal since then moved it.
      if [ -n "$ends" ] && [ $(( ends - $(date +%s) )) -lt "$LOGINS_MIN" ]; then
        ends="$(_logins_json --refresh 2>/dev/null | jq -r --arg n "$number" 'first(.accounts[]? | select(.slot == $n)) | .endsAt // empty' 2>/dev/null)"
        case "$ends" in ''|*[!0-9]*) ends="";; esac
      fi
      left=$(( ${ends:-0} - $(date +%s) ))
      if [ -n "$ends" ] && [ "$left" -le 0 ]; then
        verdict="relogin-required"
        detail="account $number ($email): its login ended $(date -r "$ends" '+%a %b %-d %H:%M') - sign in again with: swap-guard login $number (never a plain /login: it kills the live account's token)"
      elif [ -n "$ends" ] && [ "$left" -lt "$LOGINS_MIN" ]; then
        verdict="login-ending"
        detail="account $number ($email): its login ends in $(_logins_left "$left") ($(date -r "$ends" '+%a %b %-d %H:%M')) - sessions switched there would stop then; renew first: swap-guard login $number"
      elif [ "$busy_n" -gt 0 ] 2>/dev/null; then
        verdict="busy"
        detail="account $number ($email) is healthy, but $busy_n other session(s) are busy or possibly-busy - a switch flips ALL sessions within ~30s"
      else
        verdict="ok"
        detail="account $number ($email) is healthy and no other session is busy"
      fi
      ;;
  esac

  jq -cn --arg v "$verdict" --argjson t "$target_json" --argjson busy "$busy" --arg d "$detail" \
    --argjson ends "${ends:-null}" '{verdict:$v,target:$t,busy:$busy,detail:$d,loginEndsAt:$ends}'
  exit 0
}

cmd_path() {
  local kind="${1:-}"
  [ $# -gt 0 ] && shift
  local cwd_opt="" cwd
  cwd_opt="$(parse_cwd_opt "$@")" || cwd_opt=""
  cwd="$(resolve_cwd "$cwd_opt")"
  case "$kind" in
    flag)    flag_path_for "$cwd";;
    handoff) pending_path_for "$cwd";;
    # The package `/handoff <mac>` sends to another Mac (never loaded on this one).
    remote-handoff) printf '%s/handoff-outbox-%s.md\n' "$STATE_ROOT" "$(hash_cwd "$cwd")";;
    # Where overcodex's SessionStart hook looks for a package in this cwd: same
    # header and hash convention, under the Codex home a new `codex` will use.
    codex-handoff)
      local ch; ch="$(_codex_active_home)"
      mkdir -p "$ch/overcodex" 2>/dev/null
      printf '%s/overcodex/handoff-pending-%s.md\n' "$ch" "$(hash_cwd "$cwd")";;
    *) usage;;
  esac
  exit 0
}

cmd_flag() {
  local input="${1:-}"
  if [ -z "$input" ] || ! printf '%s' "$input" | jq -e . >/dev/null 2>&1; then
    echo "swap-guard flag: input is not valid JSON" >&2
    exit 1
  fi
  local mode
  mode="$(printf '%s' "$input" | jq -r '.mode // empty')"
  case "$mode" in
    handoff|restart) ;;
    *) echo 'swap-guard flag: .mode must be "handoff" or "restart"' >&2; exit 1;;
  esac
  local sid cwd created out p tmp
  sid="$(printf '%s' "$input" | jq -r '.sessionId // ""')"
  cwd="$(printf '%s' "$input" | jq -r '.cwd // empty')"
  cwd="$(resolve_cwd "$cwd")"
  created="$(printf '%s' "$input" | jq -r '.created // empty' 2>/dev/null)"
  case "$created" in ''|*[!0-9]*) created="$(date +%s)";; esac
  out="$(jq -cn --arg m "$mode" --arg s "$sid" --arg c "$cwd" --argjson t "$created" \
    '{mode:$m,sessionId:$s,cwd:$c,created:$t}')"
  p="$(flag_path_for "$cwd")"
  mkdir -p "$STATE_ROOT" 2>/dev/null || { echo "swap-guard flag: cannot create $STATE_ROOT" >&2; exit 1; }
  tmp="$p.tmp.$$"
  if printf '%s\n' "$out" > "$tmp" 2>/dev/null && mv "$tmp" "$p" 2>/dev/null; then
    printf '%s\n' "$p"
    exit 0
  fi
  rm -f "$tmp" 2>/dev/null
  echo "swap-guard flag: write failed" >&2
  exit 1
}

# _status_json <sessionId-or-empty> <cwd> — build the status JSON (factored for testability)
_status_json() {
  local sid="${1:-}" cwd="${2:-$PWD}"
  local pct="" fired=0 bannered=0
  if [ -n "$sid" ] && [ -f "$CTX_DIR/$sid.json" ]; then
    pct="$(jq -r 'if (.pct|type) == "number" then (.pct|tostring) else "" end' "$CTX_DIR/$sid.json" 2>/dev/null)" || pct=""
  fi
  if [ -n "$sid" ] && [ -f "$CTX_DIR/$sid.state" ]; then
    fired="$(jq -r '.fired // 0' "$CTX_DIR/$sid.state" 2>/dev/null)" || fired=0
    bannered="$(jq -r '.bannered // 0' "$CTX_DIR/$sid.state" 2>/dev/null)" || bannered=0
  fi
  case "$fired" in ''|*[!0-9]*) fired=0;; esac
  case "$bannered" in ''|*[!0-9]*) bannered=0;; esac

  local h pending flagf pending_out="" page="" flag_out="" arch="" created
  h="$(hash_cwd "$cwd")"
  pending="$STATE_ROOT/handoff-pending-$h.md"
  flagf="$STATE_ROOT/relaunch-$h.json"
  if [ -f "$pending" ]; then
    pending_out="$pending"
    created="$(sed -n '1s/.*created="\([0-9][0-9]*\)".*/\1/p' "$pending" 2>/dev/null)"
    [ -n "$created" ] || created="$(stat -f %m "$pending" 2>/dev/null)"
    case "$created" in
      ''|*[!0-9]*) page="";;
      *) page="$(( $(date +%s) - created ))";;
    esac
  fi
  [ -f "$flagf" ] && flag_out="$flagf"
  if [ -d "$ARCHIVE_DIR" ]; then
    arch="$(ls "$ARCHIVE_DIR" 2>/dev/null | grep -E -- "-$h(-expired)?\.md$" | sort | tail -n1)"
    [ -n "$arch" ] && arch="$ARCHIVE_DIR/$arch"
  fi

  jq -cn --arg sid "$sid" --arg pct "$pct" --arg fired "$fired" --arg bannered "$bannered" \
    --arg pending "$pending_out" --arg page "$page" --arg flagp "$flag_out" --arg arch "$arch" '
    {sessionId: (if $sid == "" then null else $sid end),
     pct: (if $pct == "" then null else ($pct|tonumber) end),
     fired: ($fired|tonumber),
     bannered: ($bannered|tonumber),
     pendingHandoff: (if $pending == "" then null else $pending end),
     pendingAgeSec: (if $page == "" then null else ($page|tonumber) end),
     flag: (if $flagp == "" then null else $flagp end),
     latestArchive: (if $arch == "" then null else $arch end)}'
}

cmd_status() {
  local who sid="" cwd=""
  if who="$(whoami_json 2>/dev/null)"; then
    sid="$(printf '%s' "$who" | jq -r '.sessionId // empty' 2>/dev/null)" || sid=""
    cwd="$(printf '%s' "$who" | jq -r '.cwd // empty' 2>/dev/null)" || cwd=""
  fi
  [ -n "$cwd" ] || cwd="$PWD"
  _status_json "$sid" "$cwd"
  exit 0
}

cmd_cancel() {
  local cwd_opt="" cwd removed=0 f
  cwd_opt="$(parse_cwd_opt "$@")" || cwd_opt=""
  cwd="$(resolve_cwd "$cwd_opt")"
  for f in "$(pending_path_for "$cwd")" "$(flag_path_for "$cwd")"; do
    if [ -f "$f" ]; then
      rm -f "$f" 2>/dev/null && { printf 'removed: %s\n' "$f"; removed=1; }
    fi
  done
  [ "$removed" -eq 1 ] || printf 'nothing to remove for cwd %s\n' "$cwd"
  exit 0
}

# _history_files <cwd> <all:0|1> — archive paths, newest first (max 20).
# Archive names are <UTC-ts>-<cwdhash>[-expired].md, so the cwd filter is a name match.
_history_files() {
  local cwd="$1" all="$2" h
  [ -d "$ARCHIVE_DIR" ] || return 0
  h="$(hash_cwd "$cwd")"
  ls "$ARCHIVE_DIR" 2>/dev/null | grep -E '^[0-9]{8}-[0-9]{6}-[0-9a-f]{12}(-expired|-replaced)?\.md$' |
    { if [ "$all" = 1 ]; then cat; else grep -E -- "-$h(-expired|-replaced)?\.md$"; fi; } |
    sort -r | head -n 20 | sed "s|^|$ARCHIVE_DIR/|"
}

cmd_history() {
  local cwd_opt="" cwd all=0 a n=0 f base hdr pcwd pcreated title bytes expired out="[]"
  for a in "$@"; do [ "$a" = "--all" ] && all=1; done
  cwd_opt="$(parse_cwd_opt "$@")" || cwd_opt=""
  cwd="$(resolve_cwd "$cwd_opt")"
  while IFS= read -r f; do
    [ -n "$f" ] && [ -f "$f" ] || continue
    n=$((n + 1))
    base="${f##*/}"
    hdr="$(head -n 1 "$f")"
    pcwd="$(printf '%s' "$hdr" | sed -n 's/^<!-- handoff cwd="\(.*\)" created="[0-9][0-9]*" -->.*$/\1/p')"
    pcreated="$(printf '%s' "$hdr" | sed -n 's/^<!-- handoff cwd=".*" created="\([0-9][0-9]*\)" -->.*$/\1/p')"
    title="$(sed -n '2s/^# *//p' "$f" | cut -c1-120)"
    bytes="$(LC_ALL=C wc -c < "$f" | tr -d ' ')"
    # (no `case` inside $(...): bash 3.2 mis-parses its `)` patterns there)
    expired=false; case "$base" in *-expired.md) expired=true;; esac
    out="$(printf '%s' "$out" | jq -c --argjson n "$n" --arg path "$f" --arg cwd "$pcwd" \
      --arg created "$pcreated" --arg title "$title" --arg bytes "$bytes" \
      --arg arch "$(printf '%s' "$base" | sed -E 's/^([0-9]{4})([0-9]{2})([0-9]{2})-([0-9]{2})([0-9]{2})([0-9]{2}).*/\1-\2-\3T\4:\5:\6Z/')" \
      --argjson expired "$expired" '
      . + [{n: $n, path: $path, title: $title, cwd: (if $cwd == "" then null else $cwd end),
            created: (if $created == "" then null else ($created | tonumber) end),
            archivedAt: $arch, expired: $expired, bytes: ($bytes | tonumber)}]')"
  done <<EOF
$(_history_files "$cwd" "$all")
EOF
  printf '%s\n' "$out"
  exit 0
}

cmd_restore() {
  local cwd_opt="" cwd force=0 sel="" src="" hdr pcwd P created_old now age
  while [ $# -gt 0 ]; do
    case "$1" in
      --force) force=1;;
      --cwd) shift; cwd_opt="${1:-}";;
      --cwd=*) cwd_opt="${1#--cwd=}";;
      --*) :;;
      *) [ -z "$sel" ] && sel="$1";;
    esac
    shift
  done
  cwd="$(resolve_cwd "$cwd_opt")"
  case "$sel" in
    '') src="$(_history_files "$cwd" 0 | head -n 1)";;
    *[!0-9]*)
      # Paths are reduced to their basename and must look like an archive name, so
      # `..`, foreign dirs, and look-alike prefixes cannot escape the archive dir.
      # Restored text is injected as trusted context — symlinks are refused too.
      local b="${sel##*/}"
      if printf '%s' "$b" | grep -Eq '^[0-9]{8}-[0-9]{6}-[0-9a-f]{12}(-expired|-replaced)?\.md$' &&
         [ -f "$ARCHIVE_DIR/$b" ] && [ ! -L "$ARCHIVE_DIR/$b" ]; then
        src="$ARCHIVE_DIR/$b"
      fi;;
    *) src="$(_history_files "$cwd" 0 | sed -n "${sel}p")";;
  esac
  if [ -z "$src" ] || [ ! -f "$src" ] || [ -L "$src" ]; then
    jq -cn --arg cwd "$cwd" --arg sel "$sel" '{error:"not-found", cwd:$cwd, selector:$sel,
      detail:"no archived package matches; run swap-guard history (paths must be inside the archive dir)"}'
    exit 1
  fi
  hdr="$(head -n 1 "$src")"
  pcwd="$(printf '%s' "$hdr" | sed -n 's/^<!-- handoff cwd="\(.*\)" created="[0-9][0-9]*" -->.*$/\1/p')"
  if [ -z "$pcwd" ]; then
    jq -cn --arg src "$src" '{error:"malformed-header", path:$src}'; exit 1
  fi
  if [ "$pcwd" != "$cwd" ]; then
    jq -cn --arg src "$src" --arg pcwd "$pcwd" --arg cwd "$cwd" '{error:"cwd-mismatch", path:$src,
      packageCwd:$pcwd, cwd:$cwd, detail:"the startup injector only loads a package in its own directory: cd there, or pass --cwd <packageCwd>"}'
    exit 1
  fi
  P="$(pending_path_for "$cwd")"
  now="$(date +%s)"
  if [ -f "$P" ] && [ "$force" -ne 1 ]; then
    created_old="$(sed -n '1s/.*created="\([0-9][0-9]*\)".*/\1/p' "$P" 2>/dev/null)"
    case "$created_old" in ''|*[!0-9]*) age=0;; *) age=$(( now - created_old ));; esac
    if [ "$age" -lt 600 ]; then
      jq -cn --arg p "$P" --argjson age "$age" '{error:"pending-exists", pending:$p, pendingAgeSec:$age,
        detail:"a live pending handoff exists for this cwd; re-run with --force to replace it"}'
      exit 1
    fi
  fi
  mkdir -p "$STATE_ROOT" "$ARCHIVE_DIR" || exit 1
  # Build the new package BEFORE touching the old one (src may be re-read below).
  { printf '<!-- handoff cwd="%s" created="%s" -->\n' "$cwd" "$now"; tail -n +2 "$src"; } > "$P.tmp.$$" ||
    { rm -f "$P.tmp.$$"; exit 1; }
  # An existing pending package (live with --force, or expired but never archived
  # because no session started here since) is moved to the archive, never lost.
  local replaced=""
  if [ -f "$P" ]; then
    replaced="$ARCHIVE_DIR/$(date -u +%Y%m%d-%H%M%S)-$(hash_cwd "$cwd")-replaced.md"
    mv "$P" "$replaced" || { rm -f "$P.tmp.$$"; exit 1; }
  fi
  mv "$P.tmp.$$" "$P" || { rm -f "$P.tmp.$$"; exit 1; }
  jq -cn --arg src "$src" --arg p "$P" --arg cwd "$cwd" --argjson now "$now" --arg rep "$replaced" \
    '{restored:$src, pending:$p, cwd:$cwd, created:$now, expiresInSec:600,
      replacedArchivedTo:(if $rep == "" then null else $rep end)}'
  exit 0
}

# --- auto: busy-aware auto-switching ------------------------------------------
# `cswap auto` knows rate limits but not whether a Claude session is mid-turn.
# A tick asks cswap for a DRY-RUN decision first; a proactive switch is deferred
# while any live session is busy (swap-guard preflight), while an at-limit /
# failover switch proceeds (every session on that account is stalled anyway).
# The real switch is a non-dry `cswap auto --once`, which re-checks under its own
# state lock and records lastSwitchAt, so cswap's cooldown keeps working.
AUTO_LABEL="com.overclaude.autoswap"
AUTO_LOG="$STATE_ROOT/auto.log"
auto_plist() { printf '%s/Library/LaunchAgents/%s.plist\n' "$HOME" "$AUTO_LABEL"; }

_auto_log() { # _auto_log <json> — append + keep the last 500 lines
  mkdir -p "$STATE_ROOT" 2>/dev/null || return 0
  printf '%s\n' "$1" >> "$AUTO_LOG"
  if [ "$(wc -l < "$AUTO_LOG" 2>/dev/null || echo 0)" -gt 600 ]; then
    tail -n 500 "$AUTO_LOG" > "$AUTO_LOG.tmp.$$" && mv "$AUTO_LOG.tmp.$$" "$AUTO_LOG"
  fi
}

_auto_notify() { # _auto_notify <message>
  command -v osascript >/dev/null 2>&1 || return 0
  osascript -e "display notification \"$(printf '%s' "$1" | tr -d '"\\')\" with title \"overclaude auto-swap\"" >/dev/null 2>&1 || true
}

_auto_tick() {
  local notify=1 probe=0 cargs=() dry rc ev trigger to_num to_email pre verdict busy_n out real_rc now
  while [ $# -gt 0 ]; do
    case "$1" in
      --no-notify) notify=0;;
      --probe) probe=1;;
      --threshold|--model|--strategy|--cooldown) cargs+=("$1" "${2:-}"); shift;;
      *) :;;
    esac
    shift
  done
  command -v cswap >/dev/null 2>&1 || { printf '%s\n' '{"event":"error","detail":"cswap not found"}'; return 1; }
  now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  local errf; errf="$(mktemp "${TMPDIR:-/tmp}/swap-guard-auto.XXXXXX")" || errf=/dev/null
  dry="$(cswap auto --once --dry-run --json ${cargs[@]+"${cargs[@]}"} 2>"$errf")"; rc=$?
  # argparse rejects bad arguments with rc 2 and no JSON on stdout — rc 2 alone is
  # indistinguishable from NO_ACTION, so treat "no events at all" as an error.
  if ! printf '%s\n' "$dry" | jq -e 'select(.event != null)' >/dev/null 2>&1; then
    out="$(jq -cn --arg ts "$now" --argjson rc "$rc" --arg err "$(tail -c 400 "$errf" 2>/dev/null)" \
      '{ts:$ts, action:"error", rc:$rc, detail:("cswap auto produced no decision: " + $err)}')"
    [ "$errf" = /dev/null ] || rm -f "$errf"
    _auto_log "$out"; printf '%s\n' "$out"; return 1
  fi
  [ "$errf" = /dev/null ] || rm -f "$errf"
  # --probe (used by `auto install`): the args parse and cswap decides — stop
  # here; a probe never switches and never writes the log.
  if [ "$probe" -eq 1 ]; then
    jq -cn --arg ts "$now" --argjson rc "$rc" '{ts:$ts, action:"probe-ok", rc:$rc}'; return 0
  fi
  ev="$(printf '%s\n' "$dry" | jq -c 'select(.event == "switch")' 2>/dev/null | tail -n 1)"
  if [ "$rc" -ne 0 ] || [ -z "$ev" ]; then
    # no-switch / blocked / error: log the last decisive event and pass cswap's exit code through
    out="$(printf '%s\n' "$dry" | jq -c --arg ts "$now" --argjson rc "$rc" \
      'select(.event != "poll") | {ts: $ts, action: "none", rc: $rc, event, reason, detail}' 2>/dev/null | tail -n 1)"
    [ -n "$out" ] || out="$(jq -cn --arg ts "$now" --argjson rc "$rc" '{ts:$ts, action:"none", rc:$rc}')"
    _auto_log "$out"; printf '%s\n' "$out"
    if [ "$rc" -eq 3 ] && [ "$notify" -eq 1 ]; then
      _auto_notify "Every account is at its limit — nothing to switch to."
    fi
    return "$rc"
  fi
  trigger="$(printf '%s' "$ev" | jq -r '.trigger // ""')"
  to_num="$(printf '%s' "$ev" | jq -r '.to.number // ""')"
  to_email="$(printf '%s' "$ev" | jq -r '.to.email // ""')"
  pre="$( (cmd_preflight "$to_num") 2>/dev/null)"
  verdict="$(printf '%s' "$pre" | jq -r '.verdict // "unknown"' 2>/dev/null)"
  busy_n="$(printf '%s' "$pre" | jq -r '(.busy // []) | length' 2>/dev/null)"
  case "$verdict" in
    relogin-required|login-ending|unknown-target)
      out="$(jq -cn --arg ts "$now" --arg v "$verdict" --arg to "$to_num" '{ts:$ts, action:"skipped", reason:$v, to:$to}')"
      _auto_log "$out"; printf '%s\n' "$out"; return 3;;
    busy)
      if [ "$trigger" = "proactive" ] || [ "$trigger" = "consume-first" ]; then
        out="$(jq -cn --arg ts "$now" --arg to "$to_num" --arg t "$trigger" --argjson n "${busy_n:-0}" \
          '{ts:$ts, action:"deferred", reason:"busy", trigger:$t, to:$to, busySessions:$n}')"
        _auto_log "$out"; printf '%s\n' "$out"; return 2
      fi;;
  esac
  out="$(cswap auto --once --json ${cargs[@]+"${cargs[@]}"} 2>/dev/null)"; real_rc=$?
  ev="$(printf '%s\n' "$out" | jq -c 'select(.event == "switch")' 2>/dev/null | tail -n 1)"
  if [ "$real_rc" -eq 0 ] && [ -n "$ev" ]; then
    to_num="$(printf '%s' "$ev" | jq -r '.to.number // ""')"
    to_email="$(printf '%s' "$ev" | jq -r '.to.email // ""')"
    out="$(printf '%s' "$ev" | jq -c --arg ts "$now" --arg v "$verdict" --argjson n "${busy_n:-0}" \
      '{ts:$ts, action:"switched", trigger, from:.from.number, to:.to.number, warnings, preflight:$v, busySessions:$n}')"
    _auto_log "$out"; printf '%s\n' "$out"
    if [ "$notify" -eq 1 ]; then
      local label
      label="$(jq -r --arg n "$to_num" '.accounts[$n].alias // empty' "$STATE_ROOT/sequence.json" 2>/dev/null)"
      _auto_notify "Switched to ${label:-${to_email%%@*}} ($trigger). A paused session continues when you send any message."
    fi
    return 0
  fi
  out="$(printf '%s\n' "$out" | jq -c --arg ts "$now" --argjson rc "$real_rc" \
    'select(.event != "poll") | {ts:$ts, action:"none", rc:$rc, event, reason, detail, note:"dry-run wanted a switch; recheck declined"}' 2>/dev/null | tail -n 1)"
  [ -n "$out" ] || out="$(jq -cn --arg ts "$now" --argjson rc "$real_rc" '{ts:$ts, action:"none", rc:$rc}')"
  _auto_log "$out"; printf '%s\n' "$out"
  return "$real_rc"
}

_auto_install() {
  local interval=180 targs=()
  while [ $# -gt 0 ]; do
    case "$1" in
      --interval) interval="${2:-180}"; shift;;
      --threshold|--model|--strategy|--cooldown) targs+=("$1" "${2:-}"); shift;;
      --no-notify) targs+=("$1");;
      *) echo "auto install: unknown option '$1' (--interval --threshold --model --strategy --cooldown --no-notify)" >&2; return 2;;
    esac
    shift
  done
  case "$interval" in ''|*[!0-9]*) echo "auto install: --interval must be whole seconds" >&2; return 1;; esac
  [ "$interval" -ge 60 ] || interval=60
  # Validate up front: a bad value would otherwise fail silently on every tick.
  local i=0 v
  while [ "$i" -lt "${#targs[@]}" ]; do
    case "${targs[$i]}" in
      --threshold)
        v="${targs[$((i + 1))]:-}"
        if ! printf '%s' "$v" | grep -Eq '^[0-9]+(\.[0-9]+)?$' ||
           ! awk -v t="$v" 'BEGIN { exit !(t >= 50 && t <= 99.9) }'; then
          echo "auto install: --threshold must be a number from 50 to 99.9 (got '$v')" >&2; return 1
        fi; i=$((i + 2));;
      --model)
        v="${targs[$((i + 1))]:-}"
        printf '%s' "$v" | grep -Eq '^[A-Za-z0-9_,.-]+$' ||
          { echo "auto install: --model must be names like Fable or 'Fable,Opus' (got '$v')" >&2; return 1; }
        i=$((i + 2));;
      --strategy)
        v="${targs[$((i + 1))]:-}"
        case "$v" in best|consume-first) ;; *) echo "auto install: --strategy must be best or consume-first (got '$v')" >&2; return 1;; esac
        i=$((i + 2));;
      --cooldown)
        v="${targs[$((i + 1))]:-}"
        case "$v" in ''|*[!0-9]*) echo "auto install: --cooldown must be whole seconds (got '$v')" >&2; return 1;; esac
        i=$((i + 2));;
      *) i=$((i + 1));;
    esac
  done
  local path_env
  path_env="$(_launchd_path_env)"
  # Dry-run one tick with these exact args before installing anything.
  local probe
  probe="$(PATH="$path_env" _auto_tick --probe --no-notify ${targs[@]+"${targs[@]}"} 2>/dev/null | tail -n 1)"
  if [ "$(printf '%s' "$probe" | jq -r '.action // "error"' 2>/dev/null)" != "probe-ok" ]; then
    echo "auto install: a test tick failed, not installing: $probe" >&2; return 1
  fi
  _launchd_install "$AUTO_LABEL" "$interval" "$path_env" "$STATE_ROOT/auto-launchd.err" \
    auto tick ${targs[@]+"${targs[@]}"}
}

# _launchd_path_env — launchd's PATH is minimal: pin the dirs of every tool a tick
# needs, in priority order — the cswap that install.sh manages first, so a stale
# brew/pip copy elsewhere can never shadow it.
_launchd_path_env() {
  local self d path_env=""
  self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
  for d in "$(dirname "$(command -v cswap 2>/dev/null || echo /usr/bin/cswap)")" "$(dirname "$self")" \
           "$(dirname "$(command -v jq 2>/dev/null || echo /usr/bin/jq)")" \
           "$(dirname "$(command -v claude 2>/dev/null || echo /usr/bin/claude)")" /opt/homebrew/bin /usr/local/bin \
           /usr/bin /bin /usr/sbin /sbin; do
    case ":$path_env:" in *":$d:"*) :;; *) path_env="${path_env:+$path_env:}$d";; esac
  done
  printf '%s\n' "$path_env"
}

# _launchd_install <label> <interval> <path_env> <stderr-file> <swap-guard args...>
# Write a LaunchAgent that runs `swap-guard <args>` every <interval> seconds, lint it,
# and (re)load it. Prints {installed, plist, intervalSec, args} JSON.
_launchd_install() {
  local label="$1" interval="$2" path_env="$3" errf="$4" self plist uid a
  shift 4
  self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
  plist="$HOME/Library/LaunchAgents/$label.plist"
  mkdir -p "$(dirname "$plist")" "$STATE_ROOT" || return 1
  xml() { printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/"/\&quot;/g'; }
  {
    printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>' \
      '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
      '<plist version="1.0"><dict>' \
      "  <key>Label</key><string>$label</string>" \
      '  <key>ProgramArguments</key><array>' \
      "    <string>$(xml "$self")</string>"
    for a in "$@"; do printf '    <string>%s</string>\n' "$(xml "$a")"; done
    # stdout -> /dev/null: every tick already logs itself to its (trimmed) log;
    # stderr is kept for genuine crashes and stays tiny.
    printf '%s\n' '  </array>' \
      "  <key>StartInterval</key><integer>$interval</integer>" \
      '  <key>RunAtLoad</key><true/>' \
      "  <key>EnvironmentVariables</key><dict><key>PATH</key><string>$(xml "$path_env")</string><key>HOME</key><string>$(xml "$HOME")</string></dict>" \
      '  <key>StandardOutPath</key><string>/dev/null</string>' \
      "  <key>StandardErrorPath</key><string>$(xml "$errf")</string>" \
      '</dict></plist>'
  } > "$plist.tmp.$$" || { rm -f "$plist.tmp.$$"; return 1; }
  # Lint BEFORE replacing, so a bad write never clobbers a working plist.
  plutil -lint "$plist.tmp.$$" >/dev/null 2>&1 ||
    { echo "install: generated plist failed plutil -lint (kept: $plist.tmp.$$)" >&2; return 1; }
  mv "$plist.tmp.$$" "$plist" || return 1
  if [ -n "${SWAP_GUARD_NO_LAUNCHCTL:-}" ]; then  # tests: generate + lint only
    jq -cn --arg p "$plist" --argjson i "$interval" '{installed:false, generated:true, plist:$p, intervalSec:$i}'
    return 0
  fi
  uid="$(id -u)"
  launchctl bootout "gui/$uid/$label" >/dev/null 2>&1 || true
  if launchctl bootstrap "gui/$uid" "$plist" 2>/dev/null; then
    jq -cn --arg p "$plist" --argjson i "$interval" --arg a "$*" '{installed:true, plist:$p, intervalSec:$i, args:$a}'
  else
    echo "install: launchctl bootstrap failed (plist written: $plist)" >&2; return 1
  fi
}

_auto_uninstall() {
  local plist uid removed=false
  plist="$(auto_plist)"; uid="$(id -u)"
  launchctl bootout "gui/$uid/$AUTO_LABEL" >/dev/null 2>&1 && removed=true
  [ -f "$plist" ] && rm -f "$plist" && removed=true
  jq -cn --argjson r "$removed" '{uninstalled:$r}'
}

_auto_status() {
  local plist uid loaded=false last=""
  plist="$(auto_plist)"; uid="$(id -u)"
  launchctl print "gui/$uid/$AUTO_LABEL" >/dev/null 2>&1 && loaded=true
  [ -f "$AUTO_LOG" ] && last="$(tail -n 5 "$AUTO_LOG" | jq -sc '.' 2>/dev/null)"
  jq -cn --argjson loaded "$loaded" --arg plist "$plist" --argjson exists "$([ -f "$plist" ] && echo true || echo false)" \
    --argjson last "${last:-[]}" --arg log "$AUTO_LOG" \
    '{loaded:$loaded, plistExists:$exists, plist:$plist, log:$log, recent:$last}'
}

cmd_auto() {
  local sub="${1:-status}"; [ $# -gt 0 ] && shift
  case "$sub" in
    tick)      _auto_tick "$@"; exit $?;;
    install)   _auto_install "$@"; exit $?;;
    uninstall) _auto_uninstall; exit 0;;
    status)    _auto_status; exit 0;;
    instant)
      case "${1:-status}" in
        on)  rm -f "$INSTANT_OFF";;
        off) mkdir -p "$STATE_ROOT" && touch "$INSTANT_OFF";;
        status) :;;
        *) echo "usage: swap-guard auto instant on|off|status" >&2; exit 1;;
      esac
      jq -cn --argjson on "$([ -f "$INSTANT_OFF" ] && echo false || echo true)" '{instantSwap:$on}'; exit 0;;
    *) echo "usage: swap-guard auto tick|install|uninstall|status|instant" >&2; exit 1;;
  esac
}

# --- keepalive: logins that never expire by accident ---------------------------
# A login's refresh token is replaced by a fresh one every time it is used, so a
# login that keeps being refreshed never expires. cswap refreshes an account's
# expired access token whenever it fetches that account's usage — the active one
# under Claude Code's own refresh locks (cswap: safe with Claude Code >= 2.1.218,
# which adopts the rotated token) — but with no session open, nothing fetches. A
# keepalive tick is one `cswap list --json` (it fetches once its 180 s cache is
# stale), plus one notification a day for any account whose login is dead. It
# never switches accounts, and overclaude itself never writes a credential: any
# refresh is cswap's. On by default (install.sh); `keepalive off` leaves a marker
# so a later install does not turn it back on.
KEEPALIVE_LABEL="com.overclaude.keepalive"
KEEPALIVE_LOG="$STATE_ROOT/keepalive.log"
KEEPALIVE_OFF="$STATE_ROOT/keepalive.off"
KEEPALIVE_INTERVAL=7200

_keepalive_log() { # _keepalive_log <json> — append + keep the last 300 lines
  mkdir -p "$STATE_ROOT" 2>/dev/null || return 0
  printf '%s\n' "$1" >> "$KEEPALIVE_LOG"
  if [ "$(wc -l < "$KEEPALIVE_LOG" 2>/dev/null || echo 0)" -gt 400 ]; then
    tail -n 300 "$KEEPALIVE_LOG" > "$KEEPALIVE_LOG.tmp.$$" && mv "$KEEPALIVE_LOG.tmp.$$" "$KEEPALIVE_LOG"
  fi
}

_keepalive_tick() {
  local notify=1 probe=0 list ts now n label stamp m out dead ending ending_ok act e msg
  while [ $# -gt 0 ]; do
    case "$1" in --no-notify) notify=0;; --probe) probe=1;; esac
    shift
  done
  ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"; now="$(date +%s)"
  command -v cswap >/dev/null 2>&1 ||
    { out="$(jq -cn --arg ts "$ts" '{ts:$ts, action:"error", detail:"cswap not found"}')"
      [ "$probe" -eq 1 ] || _keepalive_log "$out"; printf '%s\n' "$out"; return 1; }
  list="$(cswap list --json 2>/dev/null)"
  if ! printf '%s' "$list" | jq -e '.accounts | type == "array"' >/dev/null 2>&1; then
    out="$(jq -cn --arg ts "$ts" '{ts:$ts, action:"error", detail:"cswap list --json gave no accounts"}')"
    [ "$probe" -eq 1 ] || _keepalive_log "$out"; printf '%s\n' "$out"; return 1
  fi
  if [ "$probe" -eq 1 ]; then jq -cn --arg ts "$ts" '{ts:$ts, action:"probe-ok"}'; return 0; fi
  dead="$(printf '%s' "$list" | jq -r '.accounts[] | select(.usageStatus == "relogin_required") | .number' 2>/dev/null)"
  for n in $dead; do
    case "$n" in ''|*[!0-9]*) continue;; esac
    stamp="$STATE_ROOT/keepalive-alerted-$n"
    m="$(stat -f %m "$stamp" 2>/dev/null || echo 0)"
    [ $(( now - m )) -ge 86400 ] || continue
    label="$(printf '%s' "$list" | jq -r --argjson n "$n" '.accounts[] | select(.number == $n) | (.alias // (.email // "" | split("@")[0]))' 2>/dev/null)"
    [ "$notify" -eq 1 ] || continue
    command -v osascript >/dev/null 2>&1 &&
      osascript -e "display notification \"$(printf '%s needs a new login. In a terminal: swap-guard login %s' "${label:-account $n}" "$n" | tr -d '"\\')\" with title \"overclaude\"" >/dev/null 2>&1
    touch "$stamp"
  done
  # A login that came back (re-login done) clears its stamp, so a later death alerts again.
  for stamp in "$STATE_ROOT"/keepalive-alerted-*; do
    [ -f "$stamp" ] || continue
    n="${stamp##*-}"
    printf '%s\n' "$dead" | grep -qx "$n" || rm -f "$stamp"
  done
  # Logins nearing their end date (a refresh never moves it): one notification a day
  # each, from 3 days out. A dead one is already covered above.
  ending="$(_logins_json --refresh 2>/dev/null | jq -c --argjson now "$now" --argjson soon "$LOGINS_SOON" \
    '[.accounts[]? | select(.endsAt != null and .endsAt - $now < $soon) | {slot, label, active, endsAt}]' 2>/dev/null)"
  ending_ok=1; [ -n "$ending" ] || { ending="[]"; ending_ok=0; }
  printf '%s' "$ending" | jq -r '.[] | [.slot, .label, (.active | tostring), (.endsAt | tostring)] | map(tostring | gsub("[\u0000-\u001f\u007f]"; "")) | join("\u001f")' 2>/dev/null |
  while IFS=$'\x1f' read -r n label act e; do
    case "$n" in ''|*[!0-9]*) continue;; esac
    case "$e" in ''|*[!0-9]*) continue;; esac
    printf '%s\n' "$dead" | grep -qx "$n" && continue
    stamp="$STATE_ROOT/keepalive-ending-alerted-$n"
    m="$(stat -f %m "$stamp" 2>/dev/null || echo 0)"
    [ $(( now - m )) -ge 86400 ] || continue
    [ "$notify" -eq 1 ] || continue
    if [ "$e" -le "$now" ]; then msg="$(printf "%s's login has ended. Sign in again: %s" "$label" "$(_logins_fix "$n" "$act")")"
    else msg="$(printf "%s's login ends in %s. Renew: %s" "$label" "$(_logins_left $(( e - now )))" "$(_logins_fix "$n" "$act")")"; fi
    command -v osascript >/dev/null 2>&1 &&
      osascript -e "display notification \"$(printf '%s' "$msg" | tr -d '"\\')\" with title \"overclaude\"" >/dev/null 2>&1
    touch "$stamp"
  done
  # Only a good read may clear a stamp: a failed one would re-alert within the day.
  [ "$ending_ok" -eq 1 ] && for stamp in "$STATE_ROOT"/keepalive-ending-alerted-*; do
    [ -f "$stamp" ] || continue
    n="${stamp##*-}"
    printf '%s' "$ending" | jq -e --arg n "$n" 'any(.[]; .slot == $n)' >/dev/null 2>&1 || rm -f "$stamp"
  done
  # Opt-in: start idle accounts' 5-hour windows (usage was just fetched above).
  local pw; pw="$(_prewarm_tick 2>/dev/null)"; [ -n "$pw" ] || pw="[]"
  out="$(printf '%s' "$list" | jq -c --arg ts "$ts" --argjson ending "$ending" --argjson pw "$pw" \
    '{ts:$ts, action:"tick", accounts:[.accounts[] | {number, active:(.active // false), status:(.usageStatus // "unknown")}],
      dead:[.accounts[] | select(.usageStatus == "relogin_required") | .number],
      ending:[$ending[] | .slot | tonumber? // .]}
     + (if ($pw | length) > 0 then {prewarm:$pw} else {} end)')"
  _keepalive_log "$out"; printf '%s\n' "$out"
  return 0
}

_keepalive_on() {
  local interval="$KEEPALIVE_INTERVAL" path_env probe
  while [ $# -gt 0 ]; do
    case "$1" in --interval) interval="${2:-}"; shift;; esac
    shift
  done
  case "$interval" in ''|*[!0-9]*) echo "keepalive on: --interval must be whole seconds" >&2; return 1;; esac
  [ "$interval" -ge 300 ] || interval=300
  path_env="$(_launchd_path_env)"
  probe="$(PATH="$path_env" _keepalive_tick --probe --no-notify 2>/dev/null | tail -n 1)"
  if [ "$(printf '%s' "$probe" | jq -r '.action // "error"' 2>/dev/null)" != "probe-ok" ]; then
    echo "keepalive on: a test tick failed, not installing: $probe" >&2; return 1
  fi
  rm -f "$KEEPALIVE_OFF"
  _launchd_install "$KEEPALIVE_LABEL" "$interval" "$path_env" "$STATE_ROOT/keepalive-launchd.err" keepalive tick
}

_keepalive_off() {
  local plist uid removed=false
  plist="$HOME/Library/LaunchAgents/$KEEPALIVE_LABEL.plist"; uid="$(id -u)"
  if [ -z "${SWAP_GUARD_NO_LAUNCHCTL:-}" ]; then
    launchctl bootout "gui/$uid/$KEEPALIVE_LABEL" >/dev/null 2>&1 && removed=true
  fi
  [ -f "$plist" ] && rm -f "$plist" && removed=true
  mkdir -p "$STATE_ROOT" && touch "$KEEPALIVE_OFF"
  jq -cn --argjson r "$removed" '{keepalive:false, removed:$r}'
}

_keepalive_status() {
  local plist uid loaded=false last="null"
  plist="$HOME/Library/LaunchAgents/$KEEPALIVE_LABEL.plist"; uid="$(id -u)"
  [ -z "${SWAP_GUARD_NO_LAUNCHCTL:-}" ] && launchctl print "gui/$uid/$KEEPALIVE_LABEL" >/dev/null 2>&1 && loaded=true
  [ -f "$KEEPALIVE_LOG" ] && last="$(tail -n 1 "$KEEPALIVE_LOG" | jq -c . 2>/dev/null || echo null)"
  jq -cn --argjson loaded "$loaded" --argjson exists "$([ -f "$plist" ] && echo true || echo false)" \
    --argjson off "$([ -f "$KEEPALIVE_OFF" ] && echo true || echo false)" --argjson last "${last:-null}" \
    --arg plist "$plist" --arg log "$KEEPALIVE_LOG" \
    '{loaded:$loaded, plistExists:$exists, turnedOff:$off, plist:$plist, log:$log, last:$last}'
}

cmd_keepalive() {
  local sub="${1:-status}"; [ $# -gt 0 ] && shift
  case "$sub" in
    tick)   _keepalive_tick "$@"; exit $?;;
    on)     _keepalive_on "$@"; exit $?;;
    off)    _keepalive_off; exit 0;;
    status) _keepalive_status; exit 0;;
    *) echo "usage: swap-guard keepalive tick|on|off|status" >&2; exit 1;;
  esac
}

# --- logins: when each account's login ends -------------------------------------
# A /login has a fixed end: Claude Code stores refreshTokenExpiresAt (about 30 days
# after sign-in) next to the token, and refreshing does not move it. Keepalive keeps
# a login from dying of disuse, not past that date — only a new sign-in
# (swap-guard login N) starts a new one. Claude Code warns 3 days ahead, but only for
# the account it is logged into; this covers every account. Each keychain item goes
# from `security` straight into jq over a pipe — no variable, file or argv holds the
# token, and only the two dates come out. The cache holds dates and labels only.
LOGINS_CACHE="$STATE_ROOT/cache/logins.json"
LOGINS_SOON=259200   # warn this many seconds ahead (3 days, like Claude Code)
LOGINS_MIN=21600     # never switch to an account with less than this left (6 h)
LOGINS_TTL=3600      # cache age before a refresh
LOGINS_JQ_DATES='(.claudeAiOauth // {}) | [(.refreshTokenExpiresAt // ""), (.expiresAt // "")] | map(tostring) | join(" ")'

_logins_item() { # _logins_item <service> <account> — "<refreshTokenExpiresAt> <expiresAt>" in ms, or nothing
  # perl's alarm stops a keychain read that hangs (a locked keychain prompting nobody under launchd).
  perl -e 'alarm shift; exec @ARGV or exit 127' "${SWAP_KEYCHAIN_TIMEOUT:-8}" security find-generic-password -a "$2" -s "$1" -w 2>/dev/null |
    jq -r "$LOGINS_JQ_DATES" 2>/dev/null
}

_logins_refresh() { # read every account's dates, write the cache, print it
  local seq="$STATE_ROOT/sequence.json" active rows n email alias d src r a ends act out="" now prev
  command -v jq >/dev/null 2>&1 && [ -r "$seq" ] || return 1
  now="$(date +%s)"
  prev="$(jq -c '[.accounts[]? | select(.endsAt != null) | {slot, email, endsAt}]' "$LOGINS_CACHE" 2>/dev/null)" || prev=""
  [ -n "$prev" ] || prev="[]"
  active="$(jq -r '(.activeAccountNumber // "") | tostring' "$seq" 2>/dev/null)"
  rows="$(jq -r '. as $s | ($s.sequence // ($s.accounts // {} | keys))[] | tostring as $n
    | [$n, ($s.accounts[$n].email // ""), ($s.accounts[$n].alias // "")]
    | map(tostring | gsub("[\u0000-\u001f\u007f]"; "")) | join("\u001f")' "$seq" 2>/dev/null)" || return 1
  while IFS=$'\x1f' read -r n email alias; do
    case "$n" in ''|*[!0-9]*) continue;; esac
    d=""; src="none"
    # The active account's live item is the one Claude Code keeps current; cswap's
    # backup of it can lag. A custom CLAUDE_CONFIG_DIR keys the live item differently.
    if [ "$n" = "$active" ] && [ -z "${CLAUDE_CONFIG_DIR:-}" ]; then
      d="$(_logins_item "Claude Code-credentials" "${USER:-$(id -un)}")"
      [ -n "$d" ] && src="live"
      if [ -z "$d" ] && [ -r "$HOME/.claude/.credentials.json" ]; then
        d="$(jq -r "$LOGINS_JQ_DATES" "$HOME/.claude/.credentials.json" 2>/dev/null)"; [ -n "$d" ] && src="file"
      fi
    fi
    if [ -z "$d" ] && [ -n "$email" ]; then
      d="$(_logins_item claude-swap "account-$n-$email")"; [ -n "$d" ] && src="backup"
    fi
    # Nothing read (locked keychain, timeout, a session over SSH): keep the last date
    # this account had rather than forget it — the date never moves without a new sign-in.
    if [ -z "$d" ]; then
      d="$(printf '%s' "$prev" | jq -r --arg n "$n" --arg e "$email" 'first(.[] | select(.slot == $n and .email == $e)) | "\(.endsAt * 1000) "' 2>/dev/null)"
      [ -n "$d" ] && src="previous"
    fi
    r="${d%% *}"; a="${d#* }"; ends=null
    case "$r" in
      ''|*[!0-9]*) ;;
      *) ends=$(( r / 1000 ))
         # Claude Code's own rule: an access token that outlives the date by days means no real deadline.
         case "$a" in ''|*[!0-9]*) ;; *) [ $(( a / 1000 )) -gt $(( ends + LOGINS_SOON )) ] && ends=null;; esac;;
    esac
    act=false; [ "$n" = "$active" ] && act=true
    out="$out$(jq -cn --arg n "$n" --arg e "$email" --arg al "$alias" --argjson act "$act" \
      --argjson ends "$ends" --arg src "$src" \
      '{slot:$n, email:$e, alias:(if $al == "" then null else $al end),
        label:(if $al != "" then $al elif $e != "" then ($e | split("@")[0]) else "account " + $n end),
        active:$act, endsAt:$ends, source:$src}')"$'\n'
  done <<EOF
$rows
EOF
  out="$(printf '%s' "$out" | jq -sc --argjson now "$now" '{checkedAt:$now, accounts:.}' 2>/dev/null)" || return 1
  mkdir -p "$STATE_ROOT/cache" 2>/dev/null
  printf '%s\n' "$out" > "$LOGINS_CACHE.tmp.$$" && mv "$LOGINS_CACHE.tmp.$$" "$LOGINS_CACHE"
  printf '%s\n' "$out"
}

_logins_json() { # [--refresh|--cached] — the cache, refreshed when older than LOGINS_TTL
  local mode="${1:-}" m now
  now="$(date +%s)"; m="$(stat -f %m "$LOGINS_CACHE" 2>/dev/null || echo 0)"
  if [ "$mode" = --refresh ] || { [ "$mode" != --cached ] && [ $(( now - m )) -ge "$LOGINS_TTL" ]; }; then
    _logins_refresh && return 0
  fi
  [ -s "$LOGINS_CACHE" ] && jq -c . "$LOGINS_CACHE" 2>/dev/null
}

_logins_blocked() { # JSON array of slots never to switch to: the login ends within LOGINS_MIN
  local j b mode
  for mode in "" --refresh; do
    j="$(_logins_json $mode 2>/dev/null)" || { echo '[]'; return 0; }
    b="$(printf '%s' "$j" | jq -c --argjson now "$(date +%s)" --argjson min "$LOGINS_MIN" \
      '[.accounts[]? | select(.endsAt != null and .endsAt - $now < $min) | .slot]' 2>/dev/null)" || b='[]'
    # Blocking on a cached date? Read again first: a renewal since then moved it.
    [ "$b" = '[]' ] && break
  done
  printf '%s\n' "${b:-[]}"
}

_logins_fix() { # _logins_fix <slot> <active:true|false> — how to start a new login for that account
  if [ "$2" = true ]; then printf '/login in Claude Code with the same account, then: cswap add'
  else printf 'swap-guard login %s' "$1"; fi
}

_logins_left() { # _logins_left <seconds> — "25d 7h" / "5h 12m" / "8m"
  local l="$1"
  if [ "$l" -ge 86400 ]; then printf '%dd %dh' $(( l / 86400 )) $(( l % 86400 / 3600 ))
  elif [ "$l" -ge 3600 ]; then printf '%dh %dm' $(( l / 3600 )) $(( l % 3600 / 60 ))
  else printf '%dm' $(( l / 60 )); fi
}

cmd_logins() {
  local mode="" text=0 j now n label act ends st fix
  while [ $# -gt 0 ]; do
    case "$1" in
      --refresh|--cached) mode="$1";;
      --text) text=1;;
      *) echo "usage: swap-guard logins [--refresh|--cached] [--text]" >&2; exit 2;;
    esac
    shift
  done
  j="$(_logins_json $mode)" || {
    jq -cn '{error:"no-data", detail:"could not read any login (needs jq and cswap'"'"'s sequence.json)"}'; exit 1; }
  now="$(date +%s)"
  j="$(printf '%s' "$j" | jq -c --argjson now "$now" --argjson soon "$LOGINS_SOON" --argjson min "$LOGINS_MIN" '
    .accounts |= map(. + {secondsLeft:(if .endsAt == null then null else .endsAt - $now end)}
      | .state = (if .endsAt == null then "unknown" elif .secondsLeft <= 0 then "ended"
                  elif .secondsLeft < $min then "critical" elif .secondsLeft < $soon then "soon" else "ok" end))')"
  if [ "$text" -eq 0 ]; then printf '%s\n' "$j"; exit 0; fi
  printf '%s' "$j" | jq -r '.accounts[] | [.slot, .label, (.active | tostring), (.endsAt // "" | tostring), .state] | map(tostring | gsub("[\u0000-\u001f\u007f]"; "")) | join("\u001f")' |
  while IFS=$'\x1f' read -r n label act ends st; do
    case "$n" in ''|*[!0-9]*) continue;; esac
    case "$ends" in ''|*[!0-9]*) ends=""; st=unknown;; esac
    fix="$(_logins_fix "$n" "$act")"
    [ "$act" = true ] && label="$label, active"
    case "$st" in
      unknown) printf 'account %s (%s): no end date recorded\n' "$n" "$label";;
      ended)   printf 'account %s (%s): login ENDED %s — sign in again: %s\n' "$n" "$label" "$(date -r "$ends" '+%a %b %-d %H:%M')" "$fix";;
      ok)      printf 'account %s (%s): login ends %s (%s left)\n' "$n" "$label" "$(date -r "$ends" '+%a %b %-d %H:%M')" "$(_logins_left $(( ends - now )))";;
      *)       printf 'account %s (%s): login ends %s (%s left) — renew now: %s\n' "$n" "$label" "$(date -r "$ends" '+%a %b %-d %H:%M')" "$(_logins_left $(( ends - now )))" "$fix";;
    esac
  done
  exit 0
}

# --- forecast: when you run out, and which account to use first -----------------
# Reads cswap's usage cache (no network, no token). Pace = everything used across
# your accounts in the last week, spread over the longest elapsed weekly window, in
# "% of one account-week per hour". The next 7 days are simulated in 15-minute
# steps, always drawing on the account with room whose week resets soonest (room
# that resets unused is gone), each account refilling at its reset. Dead, disabled
# and ending logins don't count. An estimate: it assumes the last week's pace.
FORECAST_CACHE="$STATE_ROOT/cache/forecast.json"
FORECAST_JQ='
def W: 604800;
def ep: if type == "string" and test("(Z|\\+00:00)$")
        then (sub("\\.[0-9]+"; "") | sub("\\+00:00$"; "Z") | try fromdateiso8601 catch null)
        elif type == "number" then . else null end;
def acct_label($s; $n): ($s.accounts[$n] // {}) as $a
  | (($a.alias // "") | tostring | gsub("[\u0000-\u001f\u007f]"; "")) as $al
  | if $al != "" then $al else (($a.email // ("account " + $n)) | tostring | split("@")[0] | gsub("[\u0000-\u001f\u007f]"; "")) end;
def pool($s; $acc; $skip; $active; $order; $now; $sel):
  [ $order[] as $n | ($acc[$n] // {}) as $a | ($a.lastGood // {}) as $g
    | select((($a.lastError // "") != "invalid_grant") and (($a.authDeadStrikes // 0) == 0))
    | select((($s.accounts[$n].disabled // false) | not) and (($skip | index($n)) | not))
    | (if $sel == "week" then $g.seven_day else first($g.scoped[]? | select(.name == $sel)) end) as $w
    | select($w != null)
    | ($w.resets_at | ep) as $r0 | (($w.pct // 0) | tonumber? // 0) as $p0
    | (if $r0 == null then {p: $p0, r: ($now + W)}
       elif $r0 <= $now then {p: 0, r: ($r0 + (((($now - $r0) / W) | floor) + 1) * W)}
       else {p: $p0, r: $r0} end) as $x
    | {slot: $n, label: acct_label($s; $n), active: ($n == $active), pct: $x.p, resetKnown: ($r0 != null),
       room: ([100 - $x.p, 0] | max), resetsAt: ($x.r | floor), unusedAtReset: 0,
       ageHours: (if ($a.fetchedAt | type) == "number" then (($now - $a.fetchedAt) / 3600 | floor) else null end)} ];
def rate($a; $now):
  ([$a[] | select(.pct > 0 and .resetKnown) | (W - (.resetsAt - $now)) / 3600] | max // 0) as $el
  | ([$a[] | select(.resetKnown) | .pct] | add // 0) as $known
  | ([$a[] | select(.resetKnown | not) | .pct] | add // 0) as $unknown
  # Usage without a known window start (no reset time) counts over a full week.
  | (if $known > 0 and $el > 0 then $known / ([$el, 6] | max) else 0 end) + $unknown / 168;
def sim($a; $rate; $now):
  reduce range(0; 672) as $i ({acc: $a, out: null, first: true};
    ($now + $i * 900) as $t
    | .acc |= map(if .resetsAt <= $t then
        (if .reset1 then . else .unusedAtReset = .room | .reset1 = true end)
        | .room = 100 | .resetsAt += W else . end)
    | .need = ($rate / 4)
    | reduce (.acc | to_entries | map(select(.value.room > 0)) | sort_by(.value.resetsAt) | .[].key) as $k (.;
        if .need > 0 then ([.acc[$k].room, .need] | min) as $take
          | .acc[$k].room -= $take | .need -= $take else . end)
    | if .need > 0.0001 and .out == null then .out = $t else . end);
def report($a; $now):
  rate($a; $now) as $rate
  | sim($a; $rate; $now) as $sim
  | ([$a[] | select(.room >= 1)] | sort_by(.resetsAt) | first) as $bf
  | { ratePerHour: (($rate * 100 | round) / 100),
      accountWeeksPerWeek: (($rate * 168 / 100 * 10 | round) / 10),
      accounts: ($a | length),
      runsOutAt: $sim.out,
      useFirst: (if $bf == null then null else ($bf | {slot, label, active, room, resetsAt}) end),
      detail: [$a[] as $x | $x + {unusedAtReset: ((first($sim.acc[] | select(.slot == $x.slot)) | .unusedAtReset // 0) | round)}] };
($seq[0] // {}) as $s | (($u[0] // {}).accounts // {}) as $acc
| (($s.activeAccountNumber // "") | tostring) as $active
| [($s.sequence // ($s.accounts // {} | keys | map(tonumber? // .)))[] | tostring] as $order
| [(($lg[0] // {}).accounts // [])[] | select(.endsAt != null and .endsAt - $now < 21600) | .slot] as $skip
| { asOf: $now,
    week: report(pool($s; $acc; $skip; $active; $order; $now; "week"); $now),
    fable: report(pool($s; $acc; $skip; $active; $order; $now; "Fable"); $now) }'

_forecast_json() { # compute from the caches, write the forecast cache, print it
  local seq="$STATE_ROOT/sequence.json" usage="$STATE_ROOT/cache/usage.json" lg="$LOGINS_CACHE" out
  [ -r "$seq" ] && [ -r "$usage" ] || return 1
  [ -r "$lg" ] || lg=/dev/null
  # The cache format is cswap's; read only the version this was written against.
  jq -e '.schemaVersion == 2' "$usage" >/dev/null 2>&1 || return 1
  out="$(jq -cn --argjson now "$(date +%s)" --slurpfile seq "$seq" --slurpfile u "$usage" --slurpfile lg "$lg" \
    "$FORECAST_JQ" 2>/dev/null)" || return 1
  [ -n "$out" ] || return 1
  mkdir -p "$STATE_ROOT/cache" 2>/dev/null
  printf '%s\n' "$out" > "$FORECAST_CACHE.tmp.$$" && mv "$FORECAST_CACHE.tmp.$$" "$FORECAST_CACHE"
  printf '%s\n' "$out"
}

_forecast_text_pool() { # _forecast_text_pool <json> <pool key> <title>
  local j="$1" k="$2" title="$3" now rows out lf n label act pct room r un age
  now="$(date +%s)"
  [ "$(printf '%s' "$j" | jq -r --arg k "$k" '.[$k].accounts')" -gt 0 ] 2>/dev/null || return 0
  printf '%s: ' "$title"
  out="$(printf '%s' "$j" | jq -r --arg k "$k" '.[$k].runsOutAt // empty')"
  case "$out" in
    ''|*[!0-9]*) printf 'lasts the next 7 days at this pace' ;;
    *) printf 'runs out around %s (in %s) at this pace' "$(date -r "$out" '+%a %b %-d %H:%M')" "$(_logins_left $(( out - now > 0 ? out - now : 0 )))" ;;
  esac
  printf ' (your pace: about %s accounts'"'"' worth a week; you have %s)\n' \
    "$(printf '%s' "$j" | jq -r --arg k "$k" '.[$k].accountWeeksPerWeek')" "$(printf '%s' "$j" | jq -r --arg k "$k" '.[$k].accounts')"
  lf="$(printf '%s' "$j" | jq -r --arg k "$k" '.[$k].useFirst // empty | [.label, (.active | tostring), (.room | floor | tostring), (.resetsAt | tostring)] | join("\u001f")')"
  if [ -n "$lf" ]; then
    IFS=$'\x1f' read -r label act room r <<EOF
$lf
EOF
    case "$room$r" in *[!0-9]*) ;; *)
      if [ "$act" = true ]; then printf '  use first: %s (the one you are on) — %s%% left, resets in %s\n' "$label" "$room" "$(_logins_left $(( r - now )))"
      elif [ "$(printf '%s' "$j" | jq -r --arg k "$k" '.[$k].accounts')" -gt 1 ]; then printf '  use first: %s — %s%% left, resets in %s (sooner than the others; what is left then is lost)\n' "$label" "$room" "$(_logins_left $(( r - now )))"
      else printf '  %s — %s%% left, resets in %s\n' "$label" "$room" "$(_logins_left $(( r - now )))"; fi ;;
    esac
  fi
  rows="$(printf '%s' "$j" | jq -r --arg k "$k" '.[$k].detail[] | [.slot, .label, (.active | tostring), (.pct | floor | tostring), (.resetsAt | tostring), (.unusedAtReset | tostring), (.ageHours // "" | tostring)] | join("\u001f")')"
  while IFS=$'\x1f' read -r n label act pct r un age; do
    case "$n$pct$r$un" in ''|*[!0-9]*) continue;; esac
    [ "$act" = true ] && label="$label, active"
    printf '  %s (%s): %s%% used, resets %s' "$n" "$label" "$pct" "$(date -r "$r" '+%a %b %-d %H:%M')"
    [ "$un" -ge 5 ] && printf ' — about %s%% will go unused at this pace' "$un"
    case "$age" in ''|*[!0-9]*) ;; *) [ "$age" -ge 6 ] && printf ' (numbers %sh old)' "$age";; esac
    printf '\n'
  done <<EOF
$rows
EOF
}

cmd_forecast() {
  local text=0 j
  for a in "$@"; do case "$a" in --text) text=1;; *) echo "usage: swap-guard forecast [--text]" >&2; exit 2;; esac; done
  j="$(_forecast_json)" || { jq -cn '{error:"no-data", detail:"no usage data yet (cswap list fetches it)"}'; exit 1; }
  if [ "$text" -eq 0 ]; then printf '%s\n' "$j"; exit 0; fi
  if [ "$(printf '%s' "$j" | jq -r '.week.accounts')" = 0 ]; then
    echo "No account to forecast: every account is dead, disabled, ending, or has no usage data yet."
    exit 0
  fi
  _forecast_text_pool "$j" week "Weekly limit"
  _forecast_text_pool "$j" fable "Fable limit"
  if [ "$(printf '%s' "$j" | jq -r '.week.useFirst.active | tostring')" = false ]; then
    printf 'To follow "use first" automatically: swap-guard auto install --strategy consume-first\n'
  fi
  exit 0
}

# --- browser: the Chrome profile a re-login opens in -------------------------------
# Claude Code opens its sign-in page with `$BROWSER <url>` when BROWSER is set.
# `swap-guard login N` points it at a two-line opener for the Chrome profile that
# belongs to that account, so you approve in the right Google account. The choice:
# what you saved (`browser set`; only the profile's folder name is stored), else the
# Chrome profile signed in to the same address. Nothing about the consent page is
# automated. Chrome's profile list (with its Google addresses) is read from Local
# State; addresses are shown by `browser list` and never logged or stored.
BROWSER_PROFILES="$STATE_ROOT/browser-profiles.json"   # {"<account email>": "<profile folder>"}
CHROME_LOCAL_STATE="$HOME/Library/Application Support/Google/Chrome/Local State"

_chrome_profiles() { # JSON array [{dir, name, email}] of Chrome's profiles (empty without Chrome)
  jq -c '[(.profile.info_cache // {}) | to_entries[]
          | {dir: .key, name: (.value.name // .key), email: ((.value.user_name // "") | ascii_downcase)}]
         | map(.dir |= gsub("[\u0000-\u001f\u007f]"; "") | .name |= gsub("[\u0000-\u001f\u007f]"; "")
               | .email |= gsub("[\u0000-\u001f\u007f]"; ""))' \
    "$CHROME_LOCAL_STATE" 2>/dev/null || echo '[]'
}

_browser_for() { # _browser_for <email> — "<dir>\t<name>\t<how>" of the profile to use, or nothing
  local e profs saved
  e="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"; [ -n "$e" ] || return 1
  profs="$(_chrome_profiles)"
  saved="$(jq -r --arg e "$e" '.[$e] // empty' "$BROWSER_PROFILES" 2>/dev/null)"
  printf '%s' "$profs" | jq -r --arg e "$e" --arg s "$saved" '
    (if $s != "" then first(.[] | select(.dir == $s)) | . + {how: "saved"} else empty end)
    // (first(.[] | select(.email == $e and $e != "")) | . + {how: "same address"})
    // empty | [.dir, .name, .how] | join("\t")' 2>/dev/null | head -n 1
}

_chrome_installed() { # SWAP_CHROME_APPS: colon-separated app paths to look for (tests)
  local IFS=: d
  for d in ${SWAP_CHROME_APPS:-/Applications/Google Chrome.app:$HOME/Applications/Google Chrome.app}; do
    [ -d "$d" ] && return 0
  done
  return 1
}

_browser_opener() { # _browser_opener <dir> <file> — write the opener the login's BROWSER points at
  { printf '#!/bin/bash\n# overclaude: open the sign-in page in one Chrome profile (swap-guard login)\n'
    printf 'exec open -na "Google Chrome" --args --profile-directory=%q "$1"\n' "$1"; } > "$2" &&
    chmod 700 "$2"
}

_browser_known() { # _browser_known <email> — is it one of cswap's accounts?
  jq -e --arg e "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" \
    'any(.accounts[]?; ((.email // "") | ascii_downcase) == $e)' "$STATE_ROOT/sequence.json" >/dev/null 2>&1
}

cmd_browser() {
  local sub="${1:-list}" acct dir profs e
  [ $# -gt 0 ] && shift
  case "$sub" in
    list)
      profs="$(_chrome_profiles)"
      if [ "$(printf '%s' "$profs" | jq 'length')" -eq 0 ]; then echo "No Chrome profiles found (is Google Chrome installed?)"; exit 0; fi
      printf '%s' "$profs" | jq -r \
        --argjson acc "$(jq -c '.accounts // {}' "$STATE_ROOT/sequence.json" 2>/dev/null || echo '{}')" \
        --argjson saved "$(jq -c 'if type == "object" then . else {} end' "$BROWSER_PROFILES" 2>/dev/null || echo '{}')" '
        ($acc // {}) as $acc | ($saved // {}) as $saved
        | .[] | . as $p
        | [$acc | to_entries[] | select(((.value.email // "") | ascii_downcase) as $e
             | ($saved[$e] // "") == $p.dir or (($saved[$e] // "") == "" and $e == $p.email and $e != ""))
           | (.value.alias // ((.value.email // "") | split("@")[0]))] as $for
        | "\($p.dir)\t\($p.name)\t\(if $p.email == "" then "(not signed in)" else $p.email end)"
          + (if ($for | length) > 0 then "\t→ used for " + ($for | join(", ")) else "" end)' 2>/dev/null
      exit 0;;
    set)
      [ $# -ge 2 ] || { echo "usage: swap-guard browser set <account> <profile folder or name>" >&2; exit 2; }
      e="$(_token_label "$1" 2>/dev/null)" && _browser_known "$e" || { echo "browser set: unknown account '$1' (a slot number, alias or email from cswap list)" >&2; exit 1; }
      dir="$(_chrome_profiles | jq -r --arg p "$2" 'first(.[] | select(.dir == $p or (.name | ascii_downcase) == ($p | ascii_downcase))) | .dir // empty')"
      [ -n "$dir" ] || { echo "browser set: no Chrome profile '$2' (see: swap-guard browser list)" >&2; exit 1; }
      mkdir -p "$STATE_ROOT"
      jq -n --arg e "$(printf '%s' "$e" | tr '[:upper:]' '[:lower:]')" --arg d "$dir" \
        --argjson cur "$(jq -c 'if type == "object" then . else {} end' "$BROWSER_PROFILES" 2>/dev/null || echo '{}')" \
        '$cur + {($e): $d}' > "$BROWSER_PROFILES.tmp.$$" && mv "$BROWSER_PROFILES.tmp.$$" "$BROWSER_PROFILES"
      jq -cn --arg a "$1" --arg d "$dir" '{account:$a, profile:$d}'; exit 0;;
    clear)
      [ $# -ge 1 ] || { echo "usage: swap-guard browser clear <account>" >&2; exit 2; }
      e="$(_token_label "$1" 2>/dev/null)" && _browser_known "$e" || { echo "browser clear: unknown account '$1'" >&2; exit 1; }
      [ -f "$BROWSER_PROFILES" ] && jq --arg e "$(printf '%s' "$e" | tr '[:upper:]' '[:lower:]')" 'del(.[$e])' "$BROWSER_PROFILES" > "$BROWSER_PROFILES.tmp.$$" &&
        mv "$BROWSER_PROFILES.tmp.$$" "$BROWSER_PROFILES"
      jq -cn --arg a "$1" '{account:$a, profile:null}'; exit 0;;
    *) echo "usage: swap-guard browser list | set <account> <profile> | clear <account>" >&2; exit 2;;
  esac
}

# --- prewarm: start an idle account's 5-hour window early (opt-in) ------------------
# A 5-hour window starts at an account's first message. Inside the keepalive tick,
# for each account that is not active, has no window running, has weekly room
# (< 90%), whose login isn't ending and that wasn't pinged in the last 5 hours, send
# one tiny message so its clock starts: switch to it later and its window resets
# sooner. It runs on the account's long-lived token (swap-guard token add) through
# `token run` — the token travels in CLAUDE_CODE_OAUTH_TOKEN, never argv — from an
# empty private folder with no tools, one turn, and settings, hooks, MCP servers and
# session saving off. (Not --bare: it never reads OAuth tokens, only API keys.)
# Output is discarded; only ok/failed is logged. Off by default.
PREWARM_ON="$STATE_ROOT/prewarm.on"
PREWARM_GAP=18000   # at most one ping per account per 5 hours

_prewarm_candidates() { # JSON array of {slot, email, label, reason}; reason "" = eligible
  local now; now="$(date +%s)"
  jq -cn --argjson now "$now" --argjson gap "$PREWARM_GAP" \
    --argjson s "$(jq -c . "$STATE_ROOT/sequence.json" 2>/dev/null || echo '{}')" \
    --argjson u "$(jq -c '.accounts // {}' "$STATE_ROOT/cache/usage.json" 2>/dev/null || echo '{}')" \
    --argjson t "$(jq -c 'if type == "object" then . else {} end' "$TOKENS_JSON" 2>/dev/null || echo '{}')" \
    --argjson blocked "$(_logins_blocked)" \
    --argjson stamps "$(for f in "$STATE_ROOT"/prewarm-stamp-*; do
        case "${f##*-}" in (''|*[!0-9]*) continue;; esac
        [ -f "$f" ] && printf '{"%s":%s}\n' "${f##*-}" "$(stat -f %m "$f")"; done | jq -sc 'add // {}')" "$JQ_EPOCH"'
    (($s.activeAccountNumber // "") | tostring) as $active
    | [($s.sequence // ($s.accounts // {} | keys | map(tonumber? // .)))[] | tostring as $n
       | ($s.accounts[$n] // {}) as $a | ($u[$n] // {}) as $x | ($x.lastGood // {}) as $g
       | (($a.email // "") | ascii_downcase) as $e
       | {slot: $n, email: $e,
          label: (($a.alias // "") | tostring | gsub("[\u0000-\u001f\u007f]"; "") | if . == "" then ($e | split("@")[0]) else . end),
          reason: (
            if $n == $active then "active"
            elif ($t | keys | map(ascii_downcase) | index($e)) == null then "no-token"
            elif ($blocked | index($n)) != null then "login-ending"
            elif (($x.lastError // "") == "invalid_grant") or (($x.authDeadStrikes // 0) > 0) then "dead"
            elif ($x.fetchedAt | type) != "number" or ($now - $x.fetchedAt) > 10800 then "no-recent-usage"
            elif (($g.five_hour.resets_at // null) | ep) as $r | ($r != null and $r > $now) then "window-running"
            elif ((($g.seven_day.resets_at // null) | ep) as $r | ($r == null or $r > $now)) and (($g.seven_day.pct // 0) >= 90) then "week-nearly-used"
            elif ($now - ($stamps[$n] // 0)) < $gap then "pinged-recently"
            else "" end)}]' 2>/dev/null
}

_prewarm_ping() { # _prewarm_ping <email> — one tiny message on that account's long-lived token
  local d rc self
  # Absolute path to this script before the cd below (a relative $0 would break).
  self="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P)/$(basename "$0")"
  [ -x "$self" ] || return 1
  # A private folder in our own state dir: no shared /tmp ancestors whose CLAUDE.md
  # could load. No tools, one turn, nothing may prompt: the reply is one word.
  d="$(mktemp -d "$STATE_ROOT/prewarm.XXXXXX")" || return 1
  ( cd "$d" && perl -e 'alarm shift; exec @ARGV or exit 127' "${SWAP_PREWARM_TIMEOUT:-60}" \
      "$self" token run "$1" -- claude -p "Reply with only the word: ok" --model haiku \
      --system-prompt "Reply with one word." --tools "" --max-turns 1 --permission-prompts none \
      --setting-sources "" --settings '{"disableAllHooks":true}' \
      --strict-mcp-config --no-session-persistence </dev/null >/dev/null 2>&1 ) 2>/dev/null; rc=$?
  rm -rf "$d"
  return "$rc"
}

_prewarm_tick() { # [--dry-run] — JSON array of {slot, label, result}
  local dry=0 cands rows n e label reason res out="[]"
  [ "${1:-}" = --dry-run ] && dry=1
  [ "$dry" -eq 1 ] || [ -f "$PREWARM_ON" ] || { echo '[]'; return 0; }
  cands="$(_prewarm_candidates)"; [ -n "$cands" ] || { echo '[]'; return 0; }
  rows="$(printf '%s' "$cands" | jq -r '.[] | [.slot, .email, .label, .reason] | join("\u001f")')"
  while IFS=$'\x1f' read -r n e label reason; do
    case "$n" in ''|*[!0-9]*) continue;; esac
    if [ -n "$reason" ]; then res="skip:$reason"
    elif [ "$dry" -eq 1 ]; then res="would-ping"
    else
      touch "$STATE_ROOT/prewarm-stamp-$n"   # a failure waits the same 5 h (no retry storm)
      if _prewarm_ping "$e"; then res="ok"; else res="failed"; fi
    fi
    out="$(printf '%s' "$out" | jq -c --arg n "$n" --arg l "$label" --arg r "$res" '. + [{slot:$n, label:$l, result:$r}]')"
  done <<EOF
$rows
EOF
  printf '%s\n' "$out"
}

cmd_prewarm() {
  local sub="${1:-status}"; [ $# -gt 0 ] && shift
  case "$sub" in
    on)
      mkdir -p "$STATE_ROOT" && touch "$PREWARM_ON"
      jq -cn --argjson ka "$([ -f "$KEEPALIVE_OFF" ] && echo false || echo true)" \
        --argjson tokens "$(jq 'length' "$TOKENS_JSON" 2>/dev/null || echo 0)" \
        '{prewarm:true, keepalive:$ka, tokens:$tokens,
          warnings: ([if $ka | not then "keepalive is off, and prewarm runs inside it: swap-guard keepalive on" else empty end,
                      if $tokens == 0 then "no long-lived tokens yet: swap-guard token add <account> for each account to warm" else empty end])}'
      exit 0;;
    off) rm -f "$PREWARM_ON"; jq -cn '{prewarm:false}'; exit 0;;
    status)
      jq -cn --argjson on "$([ -f "$PREWARM_ON" ] && echo true || echo false)" --argjson c "$(_prewarm_candidates || echo '[]')" \
        '{prewarm:$on, accounts:$c}'; exit 0;;
    tick) _prewarm_tick "$@"; exit 0;;
    *) echo "usage: swap-guard prewarm on|off|status|tick [--dry-run]" >&2; exit 2;;
  esac
}

# --- token: long-lived tokens for headless jobs ---------------------------------
# `claude setup-token` prints a one-year token that only makes model requests (no
# Remote Control, no connectors) and is used through CLAUDE_CODE_OAUTH_TOKEN. It
# never rotates, so it cannot die the way a copied login does — the right thing
# for `claude -p` jobs and worker Macs, never for interactive sessions (those keep
# their /login logins, which can hot-swap and read usage). `token add` runs
# setup-token behind a pty and shows none of its output — only the sign-in URL and
# a paste-code hint — then extracts the token, keeps it only if the API accepts
# it (one direct HTTPS request), and stores it in the keychain (stdin, never argv): the
# token never reaches the screen, a file, a log or a process list.
# Metadata (no secrets) lives in $STATE_ROOT/tokens.json.
TOKEN_SERVICE="overclaude-token"
TOKENS_JSON="$STATE_ROOT/tokens.json"

_token_capture_py() {
  cat <<'PYEOF'
import fcntl, json, os, pty, re, select, signal, struct, subprocess, sys, termios, time, urllib.error, urllib.request
# argv: service account result-path probe(0|1) claude-binary
service, account, result_path, probe, claude = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == "1", sys.argv[5]
SGR = re.compile(rb"\x1b\[[0-9;:]*m")  # styling: removed, text stays joined
ANSI = re.compile(rb"\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]")  # cursor moves etc.: a break
TOK = re.compile(r"sk-ant-[a-z]{3}[0-9]{2}-[A-Za-z0-9_-]{20,}")
URL = re.compile(r"https://[^\s\"'<>]+")
SECRETISH = re.compile(r"sk-ant-\S*")

def say(msg):
    os.write(2, (msg + "\n").encode())

def clean(b):
    return ANSI.sub(b"\n", SGR.sub(b"", b)).decode("utf-8", "replace")

# setup-token's own output is never shown: its renderer may style, wrap or repaint
# the token, so no filter on its bytes can be trusted. Only complete sign-in URLs
# and a paste-code hint are echoed; the token is extracted after it exits.
pid, fd = pty.fork()
if pid == 0:
    try:  # very wide, so the token is not wrapped (wrapping is still handled below)
        fcntl.ioctl(0, termios.TIOCSWINSZ, struct.pack("HHHH", 50, 1000, 0, 0))
    except OSError:
        pass
    os.execvp(claude, [claude, "setup-token"])
raw, shown, hinted, cancelled = b"", set(), False, False
reading = os.isatty(0)
try:
    while True:
        r, _, _ = select.select([fd] + ([0] if reading else []), [], [], 1.0)
        if fd in r:
            try:
                data = os.read(fd, 65536)
            except OSError:
                data = b""
            if not data:
                break
            raw += data
            text = clean(raw)
            for m in URL.finditer(text):
                if m.end() < len(text) and m.group(0) not in shown:
                    shown.add(m.group(0))
                    say("Sign-in page (opens by itself; if not, open it): " + SECRETISH.sub("[hidden]", m.group(0)))
            if not hinted and re.search(r"(?i)paste.{0,40}code", text):
                hinted = True
                say("If the browser shows a code, paste it here and press Enter.")
        if 0 in r:
            data = os.read(0, 4096)
            if data:
                os.write(fd, data.replace(b"\n", b"\r"))
            else:
                reading = False
except KeyboardInterrupt:
    cancelled = True
res = {"cancelled": cancelled}
try:
    if cancelled:  # the child is in its own session: it never saw the Ctrl-C
        for sig, wait in ((signal.SIGTERM, 3.0), (signal.SIGKILL, 0)):
            try:
                os.kill(pid, sig)
            except ProcessLookupError:
                break
            deadline = time.time() + wait
            while time.time() < deadline and os.waitpid(pid, os.WNOHANG) == (0, 0):
                time.sleep(0.1)
    _, status = os.waitpid(pid, 0)
    res["childExit"] = os.waitstatus_to_exitcode(status)
except (ChildProcessError, KeyboardInterrupt):
    pass
text = clean(raw)
lines = re.sub(r"\r+\n|\r", "\n", text).split("\n")
bases, cands = [], []
for i, line in enumerate(lines):
    for m in TOK.finditer(line):
        t = m.group(0)
        if t not in bases:
            bases.append(t)
        if t not in cands:
            cands.append(t)
        # A token that runs to the end of its line may have been wrapped: also try
        # it joined with the leading token characters of the next lines.
        # Blank lines (left by cursor moves) are skipped; a fragment counts only if
        # it ends at a line end or a space, so following words are never glued on.
        acc, j, used = t, i + 1, 0
        if m.end() == len(line.rstrip()):
            while j < len(lines) and used < 3:
                nl = lines[j].strip()
                j += 1
                if not nl:
                    continue
                used += 1
                mm = re.match(r"[A-Za-z0-9_-]+", nl)
                if not mm or nl[mm.end():mm.end() + 1] not in ("", " ", "\t"):
                    break
                acc += mm.group(0)
                if acc not in cands:
                    cands.append(acc)
                if mm.end() < len(nl):
                    break
maximal = [b for b in bases if not any(o != b and o.startswith(b) for o in bases)]
res["tokens"] = len(maximal)
tok = None
if len(maximal) == 1 and not cancelled:
    # Only a token the API accepts is kept. A direct HTTPS request, so no settings
    # (apiKeyHelper, env), hooks or MCP servers can vouch for it or see it: any
    # answer but 401 means the bearer authenticated; 401 means a wrong/truncated
    # token; no answer means unverified, and nothing is saved.
    def accepted(c):
        # tests only (both set): no network
        want = os.environ.get("SWAP_TOKEN_TEST_VALID") if os.environ.get("SWAP_TOKEN_TEST") else None
        if want is not None:
            return c == want
        body = json.dumps({"model": "claude-haiku-5-5", "max_tokens": 1,
                           "system": "You are Claude Code, Anthropic's official CLI for Claude.",
                           "messages": [{"role": "user", "content": "ok"}]}).encode()
        req = urllib.request.Request("https://api.anthropic.com/v1/messages", data=body, method="POST", headers={
            "Authorization": "Bearer " + c, "anthropic-version": "2023-06-01", "anthropic-beta": "oauth-2025-04-20",
            "content-type": "application/json", "User-Agent": "overclaude"})
        try:
            with urllib.request.urlopen(req, timeout=30):
                return True
        except urllib.error.HTTPError as e:
            res.setdefault("verifyStatus", []).append(e.code)
            return e.code != 401
        except Exception:
            res["verifyNetwork"] = False
            return False
    for c in sorted(cands, key=len, reverse=True)[:4]:
        if accepted(c):
            tok = c
            break
    res["verified"] = tok is not None
if tok:
    # start_new_session: `security -w` prompts through /dev/tty (getpass) whenever a
    # controlling terminal exists; without one it reads the two lines from stdin.
    st = subprocess.run(["security", "add-generic-password", "-U", "-s", service, "-a", account, "-w"],
                        input=(tok + "\n" + tok + "\n").encode(), capture_output=True, start_new_session=True)
    res["stored"] = st.returncode == 0
    if probe:
        def get(url):
            req = urllib.request.Request(url, headers={"Authorization": "Bearer " + tok, "anthropic-beta": "oauth-2025-04-20",
                                                      "Content-Type": "application/json", "User-Agent": "overclaude"})
            try:
                with urllib.request.urlopen(req, timeout=10) as resp:
                    return resp.status, json.loads(resp.read().decode() or "null")
            except urllib.error.HTTPError as e:
                return e.code, None
            except Exception:
                return None, None
        code, data = get("https://api.anthropic.com/api/oauth/profile")
        res["profileStatus"] = code
        if isinstance(data, dict):
            res["email"] = (data.get("account") or {}).get("email")
        res["usageStatus"] = get("https://api.anthropic.com/api/oauth/usage")[0]
    tok = None
elif not cancelled:
    hide = lambda l: re.sub(r"[A-Za-z0-9_-]{16,}", "[hidden]", SECRETISH.sub("[hidden]", l))
    tail = [hide(l).rstrip() for l in lines if l.strip()][-12:]
    say("setup-token said (last lines):")
    for l in tail:
        say("  " + l[:300])
with open(result_path, "w") as f:
    json.dump(res, f)
PYEOF
}

# _token_label <slot|alias|email|name> — the account a token belongs to: a known slot's
# email, else the argument itself (a worker-only account this Mac has no slot for).
_token_label() {
  local raw="$1" e=""
  case "$raw" in
    *[!0-9]*) e="$(jq -r --arg a "$raw" '[.accounts[]? | select((.alias // "") == $a or ((.email // "") | ascii_downcase) == ($a | ascii_downcase)) | .email] | if length == 1 then .[0] else empty end' "$STATE_ROOT/sequence.json" 2>/dev/null)";;
    *) e="$(jq -r --arg n "$raw" '.accounts[$n].email // empty' "$STATE_ROOT/sequence.json" 2>/dev/null)";;
  esac
  e="${e:-$raw}"
  case "$e" in *[!A-Za-z0-9@._+-]*|'') echo "token: '$e' is not a usable account name" >&2; return 1;; esac
  printf '%s\n' "$e"
}

_token_add() {
  local raw="${1:-}" label tmpd res stored now exp email
  [ -n "$raw" ] || { echo "usage: swap-guard token add <slot|alias|email>" >&2; return 2; }
  command -v claude >/dev/null 2>&1 || { echo '{"error":"claude-not-found"}'; return 1; }
  command -v python3 >/dev/null 2>&1 || { echo '{"error":"python3-not-found"}'; return 1; }
  [ -t 0 ] || [ -n "${SWAP_TOKEN_TEST:-}" ] || { echo "token add: run it in a terminal (it opens a browser sign-in)" >&2; return 1; }
  label="$(_token_label "$raw")" || return 1
  echo "Creating a one-year token for $label." >&2
  echo "In the browser, make sure claude.ai is signed in as $label before you approve." >&2
  echo "The token is saved to your keychain and never shown (Ctrl-C cancels)." >&2
  mkdir -p "$STATE_ROOT" || return 1
  tmpd="$(mktemp -d "$STATE_ROOT/token.XXXXXX")" || return 1
  chmod 700 "$tmpd"
  python3 -I -c "$(_token_capture_py)" "$TOKEN_SERVICE" "$label" "$tmpd/result.json" \
    "$([ -n "${SWAP_TOKEN_NO_PROBE:-}" ] && echo 0 || echo 1)" "$(command -v claude)"
  res="$(cat "$tmpd/result.json" 2>/dev/null)"; rm -rf "$tmpd"
  [ -n "$res" ] || { echo '{"error":"capture-crashed"}'; return 1; }
  if [ "$(printf '%s' "$res" | jq -r '.cancelled // false')" = true ]; then
    echo "Cancelled. Nothing was saved." >&2; echo '{"error":"cancelled"}'; return 1
  fi
  case "$(printf '%s' "$res" | jq -r .tokens)" in
    0) echo "No token came back (sign-in cancelled or failed). Nothing was saved." >&2
       printf '%s\n' "$res" | jq -c '{error:"no-token", childExit}'; return 1;;
    1) :;;
    *) echo "More than one token appeared in the output; nothing was saved." >&2
       printf '%s\n' "$res" | jq -c '{error:"several-tokens"}'; return 1;;
  esac
  if [ "$(printf '%s' "$res" | jq -r '.verified // false')" != true ]; then
    if [ "$(printf '%s' "$res" | jq -r '.verifyNetwork | tostring')" = false ]; then
      echo "A token came back but the API could not be reached to check it; nothing was saved. Try again online." >&2
      echo '{"error":"could-not-verify"}'; return 1
    fi
    echo "A token came back but the API rejected it; nothing was saved. Try again." >&2
    echo '{"error":"token-did-not-work"}'; return 1
  fi
  stored="$(printf '%s' "$res" | jq -r '.stored // false')"
  [ "$stored" = true ] || { echo '{"error":"keychain-write-failed"}'; return 1; }
  now="$(date +%s)"; exp=$(( now + 365 * 86400 ))
  email="$(printf '%s' "$res" | jq -r '.email // empty')"
  local tmp; tmp="$(mktemp "$STATE_ROOT/tokens.XXXXXX")" || return 1
  { [ -f "$TOKENS_JSON" ] && cat "$TOKENS_JSON" || echo '{}'; } | jq --arg l "$label" --argjson c "$now" --argjson e "$exp" --argjson r "$res" \
    '.[$l] = {created:$c, expires:$e, verifiedEmail:($r.email // null), profileStatus:($r.profileStatus // null), usageStatus:($r.usageStatus // null)}' \
    > "$tmp" && mv "$tmp" "$TOKENS_JSON" || { rm -f "$tmp"; return 1; }
  if [ -n "$email" ] && [ "$(printf '%s' "$email" | tr 'A-Z' 'a-z')" != "$(printf '%s' "$label" | tr 'A-Z' 'a-z')" ]; then
    echo "WARNING: this token belongs to $email, not $label. Remove it: swap-guard token rm '$label'" >&2
  fi
  jq -cn --arg l "$label" --argjson e "$exp" --argjson r "$res" \
    '{saved:true, account:$l, expires:($e|todate), verifiedEmail:($r.email // null), canReadProfile:($r.profileStatus == 200), canReadUsage:($r.usageStatus == 200)}'
}

_token_list() {
  local now; now="$(date +%s)"
  { [ -f "$TOKENS_JSON" ] && cat "$TOKENS_JSON" || echo '{}'; } | jq -c --argjson now "$now" \
    '[to_entries[] | {account:.key, created:(.value.created|todate), expires:(.value.expires|todate),
      daysLeft:(((.value.expires - $now) / 86400) | floor), verifiedEmail:.value.verifiedEmail}]'
}

_token_rm() {
  local label tmp rc
  [ -n "${1:-}" ] || { echo "usage: swap-guard token rm <slot|alias|email>" >&2; return 2; }
  label="$(_token_label "$1")" || return 1
  security delete-generic-password -s "$TOKEN_SERVICE" -a "$label" >/dev/null 2>&1; rc=$?
  # 44 = no such item (already gone). Anything else (locked keychain, denied): the
  # token is still there, so keep listing it rather than orphan a live token.
  if [ "$rc" -ne 0 ] && [ "$rc" -ne 44 ]; then
    jq -cn --arg l "$label" --argjson rc "$rc" '{error:"delete-failed", account:$l, securityExit:$rc, fix:"unlock the login keychain, then retry"}'
    return 1
  fi
  if [ -f "$TOKENS_JSON" ]; then
    tmp="$(mktemp "$STATE_ROOT/tokens.XXXXXX")" && jq --arg l "$label" 'del(.[$l])' "$TOKENS_JSON" > "$tmp" && mv "$tmp" "$TOKENS_JSON"
  fi
  jq -cn --arg l "$label" '{removed:$l}'
}

# token run <account> -- <cmd...>: run one command with that account's token. The
# token goes into that command's environment only (the way Claude Code reads it).
_token_run() {
  local label tok
  [ -n "${1:-}" ] || { echo "usage: swap-guard token run <slot|alias|email> -- <command...>" >&2; return 2; }
  label="$(_token_label "$1")" || return 1; shift
  [ "${1:-}" = "--" ] && shift
  [ $# -gt 0 ] || { echo "token run: no command given (example: swap-guard token run work -- claude -p 'hi')" >&2; return 2; }
  tok="$(security find-generic-password -s "$TOKEN_SERVICE" -a "$label" -w 2>/dev/null)"
  [ -n "$tok" ] || { echo "token run: no token for $label (create one: swap-guard token add $label)" >&2; return 1; }
  CLAUDE_CODE_OAUTH_TOKEN="$tok" exec "$@"
}

cmd_token() {
  local sub="${1:-list}"; [ $# -gt 0 ] && shift
  case "$sub" in
    add)  _token_add "$@"; exit $?;;
    list) _token_list; exit 0;;
    rm)   _token_rm "$@"; exit $?;;
    run)  _token_run "$@"; exit $?;;
    *) echo "usage: swap-guard token add|list|rm|run" >&2; exit 1;;
  esac
}

# --- fleet: other Macs over Tailscale --------------------------------------------
# The hub (this Mac) keeps a list of worker Macs in $STATE_ROOT/fleet.json (host
# names only — no secrets) and asks each one over SSH (key-based, BatchMode: never
# a password prompt) for a status snapshot. Logins are never copied: every Mac logs
# in to its own accounts; usage limits are per account, so the hub's own cswap
# numbers already cover the whole fleet.
FLEET_JSON="$STATE_ROOT/fleet.json"
FLEET_CACHE="$STATE_ROOT/cache/fleet.json"

_fleet_ssh() { # _fleet_ssh <host> <remote command...>
  local h="$1"; shift
  ssh -o BatchMode=yes -o ConnectTimeout="${SWAP_FLEET_TIMEOUT:-5}" -o ServerAliveInterval=2 \
      -o ServerAliveCountMax=2 -o StrictHostKeyChecking=accept-new -- "$h" "$@"
}

# The snapshot a Mac reports about itself: tab-separated key/value lines, so the
# remote side needs nothing beyond bash (jq may be missing there).
_fleet_probe_script() {
  cat <<'EOF'
export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
printf 'host\t%s\n' "$(scutil --get LocalHostName 2>/dev/null || hostname -s)"
printf 'kit\t%s\n' "$(cat "$HOME/.claude-swap-backup/kit-version" 2>/dev/null)"
printf 'claude\t%s\n' "$(claude --version 2>/dev/null | awk '{print $1}')"
printf 'swapguard\t%s\n' "$(command -v swap-guard >/dev/null 2>&1 && echo yes || echo no)"
printf 'sessions\t%s\n' "$(swap-guard sessions 2>/dev/null | tr -d '\n')"
printf 'accounts\t%s\n' "$(cswap list --json 2>/dev/null | tr -d '\n')"
printf 'keepalive\t%s\n' "$(swap-guard keepalive status 2>/dev/null | tr -d '\n')"
printf 'load\t%s\n' "$(sysctl -n vm.loadavg 2>/dev/null | awk '{print $2}')"
printf 'memgb\t%s\n' "$(( $(sysctl -n hw.memsize 2>/dev/null || echo 0) / 1073741824 ))"
printf 'cpus\t%s\n' "$(sysctl -n hw.ncpu 2>/dev/null)"
printf 'done\t1\n'
EOF
}

# _fleet_snapshot_json <name> <probe-output> — one Mac's row as JSON.
_fleet_snapshot_json() {
  local name="$1" raw="$2" k v
  local host="" kit="" cl="" sgp="" ses="[]" acc="{}" ka="{}" load="" mem="" cpus=""
  while IFS="$(printf '\t')" read -r k v; do
    case "$k" in
      host) host="$v";; kit) kit="$v";; claude) cl="$v";; swapguard) sgp="$v";;
      sessions) printf '%s' "$v" | jq -e 'type == "array"' >/dev/null 2>&1 && ses="$v";;
      accounts) printf '%s' "$v" | jq -e 'type == "object"' >/dev/null 2>&1 && acc="$v";;
      keepalive) printf '%s' "$v" | jq -e 'type == "object"' >/dev/null 2>&1 && ka="$v";;
      load) load="$v";; memgb) mem="$v";; cpus) cpus="$v";;
    esac
  done <<EOF
$raw
EOF
  jq -cn --arg name "$name" --arg host "$host" --arg kit "$kit" --arg cl "$cl" --arg sg "$sgp" \
    --argjson ses "$ses" --argjson acc "$acc" --argjson ka "$ka" --arg load "$load" --arg mem "$mem" --arg cpus "$cpus" \
    '{name:$name, online:true, host:$host, kit:(if $kit == "" then null else $kit end), claude:(if $cl == "" then null else $cl end),
      overclaude:($sg == "yes"),
      sessions:($ses | length), busy:([$ses[] | select(.status == "busy")] | length),
      accounts:[($acc.accounts // [])[] | {email, active:(.active // false), status:(.usageStatus // "unknown")}],
      dead:[($acc.accounts // [])[] | select(.usageStatus == "relogin_required") | .email],
      keepalive:($ka.loaded // false),
      load:($load | tonumber? // null), memGB:($mem | tonumber? // null), cpus:($cpus | tonumber? // null)}'
}

_fleet_hosts() { jq -r '.hosts[]?.name' "$FLEET_JSON" 2>/dev/null; }

# A probe answer counts only if it ran to the end (a host killed at the deadline can
# leave half an answer).
_fleet_complete() { printf '%s\n' "$1" | grep -q "^done$(printf '\t')1"; }

_fleet_kill_tree() { # _fleet_kill_tree <pid> — the process and everything under it
  local c
  for c in $(pgrep -P "$1" 2>/dev/null); do _fleet_kill_tree "$c"; done
  kill "$1" 2>/dev/null
}

_fleet_valid_host() { case "$1" in ''|-*|*[!A-Za-z0-9._@-]*) return 1;; esac; return 0; }
_fleet_valid_email() { case "$1" in ''|-*|*[!A-Za-z0-9@._+-]*) return 1;; esac; return 0; }

# fleet follow --from <email> --to <email> (runs ON a worker, called by the hub's
# fleet swap): under this Mac's instant-swap lock, switch only if it is still on
# <from>, then record the swap so this Mac's own rate-limit hook reuses it instead
# of switching again.
_fleet_follow() {
  local from="" to="" model="" lock i=0 list act to_n from_n sw now
  while [ $# -gt 0 ]; do
    case "$1" in --from) from="${2:-}"; shift;; --to) to="${2:-}"; shift;; --model) model="${2:-}"; shift;; esac
    shift
  done
  _fleet_valid_email "$from" && _fleet_valid_email "$to" || { echo '{"switched":false,"reason":"bad-arguments"}'; return 1; }
  command -v cswap >/dev/null 2>&1 || { echo '{"switched":false,"reason":"cswap-not-found"}'; return 1; }
  # `swap-guard auto instant off` on this Mac means no automatic switching here,
  # whichever Mac asks.
  [ -f "$INSTANT_OFF" ] && { echo '{"switched":false,"reason":"instant-swap-off"}'; return 0; }
  mkdir -p "$STATE_ROOT" || return 1
  lock="$STATE_ROOT/ratelimit.lock"
  # This Mac's own instant swap may hold the lock through a usage fetch and a switch;
  # waiting it out lets the check below see what it did.
  until mkdir "$lock" 2>/dev/null; do
    if [ -n "$(find "$lock" -maxdepth 0 -mmin +2 2>/dev/null)" ]; then rmdir "$lock" 2>/dev/null; continue; fi
    i=$((i + 1)); [ "$i" -lt "${SWAP_FOLLOW_LOCK_WAIT:-45}" ] || { echo '{"switched":false,"reason":"lock-busy"}'; return 1; }
    sleep 1
  done
  # The path is baked in now: at EXIT this function's locals are out of scope.
  trap "rmdir $(printf '%q' "$lock") 2>/dev/null" EXIT
  list="$(cswap list --json 2>/dev/null)"
  act="$(printf '%s' "$list" | jq -r 'first(.accounts[]? | select(.active == true)) | .email // empty' 2>/dev/null)"
  if [ "$(printf '%s' "$act" | tr 'A-Z' 'a-z')" != "$(printf '%s' "$from" | tr 'A-Z' 'a-z')" ]; then
    jq -cn --arg a "$act" '{switched:false, reason:"not-on-that-account", active:$a}'; return 0
  fi
  to_n="$(printf '%s' "$list" | jq -r --arg e "$to" 'first(.accounts[]? | select((.email // "" | ascii_downcase) == ($e | ascii_downcase))) | .number // empty' 2>/dev/null)"
  from_n="$(printf '%s' "$list" | jq -r '.activeAccountNumber // empty' 2>/dev/null)"
  [ -n "$to_n" ] || { echo '{"switched":false,"reason":"target-not-logged-in-here"}'; return 1; }
  if _logins_blocked | jq -e --arg n "$to_n" 'index($n) != null' >/dev/null 2>&1; then
    echo '{"switched":false,"reason":"target-login-ending"}'; return 1
  fi
  sw="$(cswap switch "$to_n" --json 2>/dev/null)" &&
    [ "$(printf '%s' "$sw" | jq -r '.switched // false' 2>/dev/null)" = true ] ||
    { jq -cn --arg d "$(printf '%s' "$sw" | tail -c 200)" '{switched:false, reason:"switch-failed", detail:$d}'; return 1; }
  now="$(date +%s)"
  jq -cn --argjson ts "$now" --arg f "$from_n" --arg to "$to_n" '{ts:$ts, from:$f, to:$to, model:"", source:"fleet"}' \
    > "$RL_LAST.tmp.$$" && mv "$RL_LAST.tmp.$$" "$RL_LAST"
  # Like instant swap: remember <from> is capped here (5 h, or until the window the
  # hub saw at 100% resets — unknown here, so 5 h), so a later cap never hops back.
  case "$model" in *[!A-Za-z0-9._-]*) model="";; esac
  jq -cn --arg f "$from_n" --arg m "$model" --argjson now "$now" \
      --argjson prev "$(jq -c 'if type == "array" then . else [] end' "$RL_CAPPED" 2>/dev/null || echo '[]')" \
    '[$prev[] | select(.until > $now and (.slot != $f or .model != $m))] + [{slot:$f, model:$m, until:($now + 18000)}]' \
    > "$RL_CAPPED.tmp.$$" 2>/dev/null && mv "$RL_CAPPED.tmp.$$" "$RL_CAPPED"
  _auto_log "$(jq -cn --argjson ts "$now" --arg f "$from_n" --arg to "$to_n" '{ts:($ts|todate), source:"fleet", action:"switched", from:$f, to:$to}')"
  jq -cn --arg f "$from" --arg t "$to" '{switched:true, from:$f, to:$t}'
}

_fleet_add() {
  local h="${1:-}" raw row tmp
  _fleet_valid_host "$h" || { echo "usage: swap-guard fleet add <tailscale-name>   (letters, digits, . _ - @ only)" >&2; return 2; }
  echo "Checking SSH to ${h}…" >&2
  if ! raw="$(_fleet_ssh "$h" bash -s < <(_fleet_probe_script) 2>/dev/null)" || ! _fleet_complete "$raw"; then
    cat >&2 <<EOF
Could not reach $h over SSH without a password. To set it up (once per Mac):
  1. On $h: System Settings → General → Sharing → turn on Remote Login.
  2. On this Mac: ssh-copy-id $h   (asks for $h's login password once;
     no key yet? first run: ssh-keygen -t ed25519)
  3. Check: ssh $h true   — then run this again.
(Tailscale SSH also works, but on macOS only with the open-source tailscaled,
not the App Store or standalone Tailscale app.)
EOF
    jq -cn --arg h "$h" '{error:"ssh-failed", host:$h}'; return 1
  fi
  row="$(_fleet_snapshot_json "$h" "$raw")"
  mkdir -p "$STATE_ROOT" || return 1
  tmp="$(mktemp "$STATE_ROOT/fleet.XXXXXX")" || return 1
  { [ -f "$FLEET_JSON" ] && cat "$FLEET_JSON" || echo '{"hosts":[]}'; } |
    jq --arg h "$h" --argjson now "$(date +%s)" '.hosts = ([.hosts[]? | select(.name != $h)] + [{name:$h, addedAt:$now}])' > "$tmp" &&
    mv "$tmp" "$FLEET_JSON" || { rm -f "$tmp"; return 1; }
  if [ "$(printf '%s' "$row" | jq -r .overclaude)" != true ]; then
    echo "Added $h, but overclaude is not installed there. On $h: pipx install overclaude && overclaude install" >&2
  fi
  [ "$(printf '%s' "$row" | jq '.accounts | length')" -gt 0 ] ||
    echo "No accounts are logged in on $h yet. On $h: claude, then /login, then cswap add" >&2
  printf '%s\n' "$row" | jq -c '{added:.name, overclaude, kit, accounts:(.accounts | length)}'
}

_fleet_rm() {
  local h="${1:-}" tmp
  [ -n "$h" ] || { echo "usage: swap-guard fleet rm <name>" >&2; return 2; }
  [ -f "$FLEET_JSON" ] || { jq -cn --arg h "$h" '{removed:false, host:$h}'; return 0; }
  tmp="$(mktemp "$STATE_ROOT/fleet.XXXXXX")" || return 1
  jq --arg h "$h" '.hosts = [.hosts[]? | select(.name != $h)]' "$FLEET_JSON" > "$tmp" && mv "$tmp" "$FLEET_JSON"
  jq -cn --arg h "$h" '{removed:true, host:$h}'
}

# fleet status: every Mac in parallel (this one first), one JSON array; cached for
# the statusline and doctor. --text prints one line per Mac instead.
_fleet_status() {
  local text=0 d h i=0 pids="" out rows
  [ "${1:-}" = --text ] && text=1
  d="$(mktemp -d "${TMPDIR:-/tmp}/swap-guard-fleet.XXXXXX")" || return 1
  bash -s < <(_fleet_probe_script) > "$d/0.raw" 2>/dev/null &
  pids="$!"
  for h in $(_fleet_hosts); do
    _fleet_valid_host "$h" || continue
    i=$((i + 1)); printf '%s' "$h" > "$d/$i.name"
    ( _fleet_ssh "$h" bash -s < <(_fleet_probe_script) > "$d/$i.raw" 2>/dev/null || rm -f "$d/$i.raw" ) &
    pids="$pids $!"
  done
  # A hung host never holds the answer: anything not back in 15 s counts as offline.
  ( sleep "${SWAP_FLEET_DEADLINE:-15}"; for p in $pids; do _fleet_kill_tree "$p"; done ) >/dev/null 2>&1 &
  local watchdog=$!
  for p in $pids; do wait "$p" 2>/dev/null; done
  _fleet_kill_tree "$watchdog"; wait "$watchdog" 2>/dev/null
  rows="$(_fleet_snapshot_json "this Mac" "$(cat "$d/0.raw" 2>/dev/null)" | jq -c '.self = true')"
  local n=1
  while [ "$n" -le "$i" ]; do
    h="$(cat "$d/$n.name")"
    if [ -s "$d/$n.raw" ] && _fleet_complete "$(cat "$d/$n.raw")"; then
      rows="$rows
$(_fleet_snapshot_json "$h" "$(cat "$d/$n.raw")")"
    else
      rows="$rows
$(jq -cn --arg h "$h" '{name:$h, online:false}')"
    fi
    n=$((n + 1))
  done
  rm -rf "$d"
  out="$(printf '%s\n' "$rows" | jq -sc --argjson now "$(date +%s)" '{checkedAt:$now, macs:.}')"
  mkdir -p "$STATE_ROOT/cache" && printf '%s\n' "$out" > "$FLEET_CACHE.tmp.$$" && mv "$FLEET_CACHE.tmp.$$" "$FLEET_CACHE"
  if [ "$text" -eq 1 ]; then
    printf '%s' "$out" | jq -r '.macs[] |
      if .online then
        "\(.name)\(if .self then "" else " (" + .host + ")" end): \(.sessions) session(s)\(if .busy > 0 then ", \(.busy) busy" else "" end)"
        + " · load \(.load // "?") on \(.cpus // "?") cores · \(.memGB // "?") GB"
        + " · overclaude \(.kit // "not installed")"
        + " · accounts: \(if (.accounts | length) == 0 then "none" else ([.accounts[] | (.email | split("@")[0]) + (if .active then "*" else "" end) + (if .status == "relogin_required" then " ⚠ relogin" else "" end)] | join(", ")) end)"
        + (if .keepalive then "" else " · keepalive off" end)
      else "\(.name): offline (no SSH answer)" end'
  else
    printf '%s\n' "$out"
  fi
}

# fleet swap [--from <email>] [--model M] [--dry-run] [--notify]: move every worker
# Mac whose active account is capped (or is <email>) to the account with the most
# room among those logged in THERE, judged by this Mac's usage numbers (limits are
# per account, so the hub's figures cover every Mac); ties go to the account whose
# week resets soonest. Each worker switches with its own cswap — nothing is copied.
# The instant-swap hook runs it in the background after switching this Mac.
_fleet_swap() {
  local from="" model="" dry=0 notify=0 list st plan out="" row name to afrom rc sw moved=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --from) from="${2:-}"; shift;; --model) model="${2:-}"; shift;;
      --dry-run) dry=1;; --notify) notify=1;;
    esac
    shift
  done
  [ -n "$(_fleet_hosts)" ] || { echo '[]'; return 0; }
  local margs=""
  case "$model" in ''|*[!A-Za-z0-9._-]*) :;; *) margs=" --model '$model'";; esac
  list="$(cswap list --json 2>/dev/null)"
  printf '%s' "$list" | jq -e '.accounts' >/dev/null 2>&1 || { echo '{"error":"cswap-list-failed"}'; return 1; }
  st="$(_fleet_status 2>/dev/null)"
  printf '%s' "$st" | jq -e '.macs' >/dev/null 2>&1 || { echo '{"error":"fleet-status-failed"}'; return 1; }
  local capped; capped="$(jq -c 'if type == "array" then . else [] end' "$RL_CAPPED" 2>/dev/null)" || capped="[]"
  [ -n "$capped" ] || capped="[]"
  plan="$(jq -cn --argjson hub "$list" --argjson st "$st" --arg from "$from" --arg model "$model" --argjson dry "$dry" \
      --argjson capped "$capped" \
      --argjson now "$(date +%s)" "$JQ_EPOCH"'
    def pct($w): if $w == null then 0
      elif (($w.resetsAt // "") | ep) as $r | ($r != null and $r <= $now) then 0
      else ($w.pct // 0) end;
    def lc: ascii_downcase;
    def okstatus: . as $s | ["ok", "token_expired", "unavailable"] | index($s) != null;
    def acct($e): first($hub.accounts[]? | select((.email // "" | lc) == ($e | lc))) // null;
    def usage($a): $a.usage // $a.lastGoodUsage;
    def score($u): [pct($u.fiveHour), pct($u.sevenDay),
                    (if $model == "" then 0 else pct([$u.scoped[]? | select((.name | lc) == ($model | lc))] | first) end)] | max;
    def listed($a): [$capped[] | select(.slot == ($a.number | tostring) and .until > $now
                      and (.model == "" or (.model | lc) == ($model | lc)))] | length > 0;
    # A target needs the same filters instant swap uses on this Mac: not disabled,
    # a usable login here, numbers to judge by, and not recorded as capped.
    def room($e): acct($e) as $a
      | if $a == null or ($a.disabled // false) or (($a.usageStatus // "") | okstatus | not) or listed($a)
           or usage($a) == null then null
        else {score: score(usage($a)), reset: (((usage($a).sevenDay // {}).resetsAt // "") | ep // 99999999999)} end;
    # Capped = recorded as capped here, or a window at 100%. Unknown is not capped.
    def capped($e): acct($e) as $a
      | $a != null and (listed($a) or (usage($a) != null and score(usage($a)) >= 100));
    [$st.macs[] | select(.online and ((.self // false) | not))
     | (first(.accounts[]? | select(.active)) // null) as $act
     | if $act == null then {name, action:"skip", reason:"no-active-account"}
       elif $from != "" and (($act.email // "") | lc) != ($from | lc) then {name, action:"skip", reason:"not-on-that-account", active:$act.email}
       elif $from == "" and (capped($act.email) | not) then {name, action:"skip", reason:"active-has-room", active:$act.email}
       else ([.accounts[] | select((.active | not) and (.status | okstatus))
              | . + {r: room(.email)} | select(.r != null and .r.score < 100)]
             | sort_by([.r.score, .r.reset]) | first) as $best
         | if $best == null then {name, action:"none", reason:"no-account-with-room-there", from:$act.email}
           else {name, action:(if $dry == 1 then "would-switch" else "switch" end), from:$act.email, to:$best.email, score:$best.r.score} end
       end]' 2>/dev/null)" || { echo '{"error":"plan-failed"}'; return 1; }
  while IFS= read -r row; do
    [ -n "$row" ] || continue
    if [ "$(printf '%s' "$row" | jq -r .action)" = switch ]; then
      name="$(printf '%s' "$row" | jq -r .name)"; to="$(printf '%s' "$row" | jq -r .to)"
      afrom="$(printf '%s' "$row" | jq -r .from)"
      if _fleet_valid_host "$name" && _fleet_valid_email "$to" && _fleet_valid_email "$afrom"; then
        # stdin closed: ssh would otherwise swallow the rest of this loop's rows.
        sw="$(_fleet_ssh "$name" "PATH=\"\$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:\$PATH\" swap-guard fleet follow --from '$afrom' --to '$to'$margs" </dev/null 2>/dev/null)"; rc=$?
        if [ "$rc" -eq 0 ] && [ "$(printf '%s' "$sw" | jq -r '.switched // false' 2>/dev/null)" = true ]; then
          row="$(printf '%s' "$row" | jq -c '.action = "switched"')"; moved="${moved:+$moved, }$name"
        elif [ "$(printf '%s' "$sw" | jq -r '.reason // empty' 2>/dev/null)" = not-on-that-account ]; then
          row="$(printf '%s' "$row" | jq -c --arg a "$(printf '%s' "$sw" | jq -r '.active // ""')" '.action = "skip" | .reason = "moved-already" | .active = $a')"
        elif [ "$rc" -eq 2 ]; then
          row="$(printf '%s' "$row" | jq -c '.action = "failed" | .detail = "overclaude there is older than 1.5 — on that Mac: overclaude update"')"
        else
          row="$(printf '%s' "$row" | jq -c --argjson rc "$rc" --arg d "$(printf '%s' "$sw" | tail -c 200)" '.action = "failed" | .rc = $rc | .detail = $d')"
        fi
      else
        row="$(printf '%s' "$row" | jq -c '.action = "failed" | .detail = "unsafe host or account name"')"
      fi
    fi
    out="${out:+$out
}$row"
  done <<EOF
$(printf '%s' "$plan" | jq -c '.[]')
EOF
  [ "$notify" -eq 1 ] && [ -n "$moved" ] && _auto_notify "Also switched on your other Macs: $moved."
  printf '%s\n' "$out" | jq -sc '.'
}

FLEET_SWAP_OFF="$STATE_ROOT/fleet-swap.off"

cmd_fleet() {
  local sub="${1:-status}"; [ $# -gt 0 ] && shift
  case "$sub" in
    add)    _fleet_add "$@"; exit $?;;
    rm)     _fleet_rm "$@"; exit $?;;
    list)   { [ -f "$FLEET_JSON" ] && jq -c '[.hosts[]?.name]' "$FLEET_JSON"; } || echo '[]'; exit 0;;
    status) _fleet_status "$@"; exit $?;;
    swap)   _fleet_swap "$@"; exit $?;;
    follow) _fleet_follow "$@"; exit $?;;
    # Whether an instant swap here also moves the other Macs off the capped account
    # (default on; remembered). SWAP_NO_FLEET_SWAP=1 still turns it off per process.
    auto)
      case "${1:-status}" in
        on)  rm -f "$FLEET_SWAP_OFF";;
        off) mkdir -p "$STATE_ROOT" && touch "$FLEET_SWAP_OFF";;
        status) :;;
        *) echo "usage: swap-guard fleet auto on|off|status" >&2; exit 2;;
      esac
      jq -cn --argjson on "$([ -f "$FLEET_SWAP_OFF" ] && echo false || echo true)" '{fleetAuto:$on}'; exit 0;;
    *) echo "usage: swap-guard fleet add <host> | rm <host> | list | status [--text] | swap [--from <email>] [--model M] [--dry-run] | auto on|off" >&2; exit 2;;
  esac
}

# --- remote: Claude sessions on a worker Mac, driven from claude.ai/code ----------
# `remote start` launches `claude --bg --remote-control <name>` on the worker over
# SSH (tmux when that Claude Code has no --bg), so the session shows up in
# claude.ai/code and the phone app under <name>. Remote Control needs a /login login
# on the worker (setup-tokens don't work). Free text (goal, folder) travels base64-
# encoded inside the script on stdin, never through the remote shell's parser, and
# the goal reaches claude as "Task: <goal>", so it can never be read as an option
# or a subcommand. Every SSH call has a deadline: a hung worker never hangs us.
_remote_valid_ref() { case "$1" in ''|-*|*[!A-Za-z0-9._-]*) return 1;; esac; return 0; }
_remote_is_id() { case "$1" in [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) return 0;; esac; return 1; }
_remote_path='export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"'

# _remote_ssh <deadline-secs> <stdin-file|""> <host> <remote command...>
# The answer on stdout; status 124 when the deadline hit, 255 when ssh itself failed.
_remote_ssh() {
  local secs="$1" in="$2" d p w rc; shift 2
  d="$(mktemp -d "${TMPDIR:-/tmp}/swap-guard-remote.XXXXXX")" || return 1
  _RM_D="$d"; _remote_trap
  [ -n "$in" ] || in=/dev/null
  ( _fleet_ssh "$@" < "$in" > "$d/out" 2>/dev/null; echo "$?" > "$d/rc" ) &
  p=$!; _RM_P="$p"
  # `&&`: a watchdog whose sleep was killed (the call finished first) does nothing.
  ( sleep "$secs" && { touch "$d/timeout"; _fleet_kill_tree "$p"; } ) >/dev/null 2>&1 &
  w=$!; _RM_W="$w"
  wait "$p" 2>/dev/null
  _fleet_kill_tree "$w"; wait "$w" 2>/dev/null
  _RM_P=""; _RM_W=""
  cat "$d/out" 2>/dev/null
  # A call that finished on its own wins, even if the deadline fired at the same
  # moment; a status from being killed by the watchdog (>128) is the timeout.
  rc="$(cat "$d/rc" 2>/dev/null)"; case "$rc" in ''|*[!0-9]*) rc="";; esac
  if [ -e "$d/timeout" ] && { [ -z "$rc" ] || [ "$rc" -gt 128 ]; }; then rc=124; fi
  rm -rf "$d"; _RM_D=""
  case "$rc" in ''|*[!0-9]*) rc=1;; esac
  return "$rc"
}

# Killed mid-call (Ctrl-C, TERM): remove the temp files and stop the ssh/watchdog
# children. Globals, not locals: the trap runs after the function has returned.
_RM_D=""; _RM_SF=""; _RM_P=""; _RM_W=""; _RM_PKGF=""; _RM_FORCE=0
_remote_cleanup() {
  [ -n "$_RM_P" ] && _fleet_kill_tree "$_RM_P"
  [ -n "$_RM_W" ] && _fleet_kill_tree "$_RM_W"
  [ -n "$_RM_D" ] && rm -rf "$_RM_D"
  [ -n "$_RM_SF" ] && rm -f "$_RM_SF"
  _RM_P=""; _RM_W=""; _RM_D=""; _RM_SF=""
}
_remote_trap() { trap '_remote_cleanup' EXIT; trap '_remote_cleanup; exit 130' INT TERM; }

_remote_start_script() { # <name> <dir-b64> <goal-b64> [<package-b64> <force:0|1>]
  printf '%s\n' "$_remote_path"
  printf "n='%s'; db='%s'; gb='%s'; pb='%s'; force='%s'\n" "$1" "$2" "$3" "${4:-}" "${5:-0}"
  cat <<'EOF'
dec() { [ -n "$1" ] || return 0; printf '%s' "$1" | base64 -d 2>/dev/null || printf '%s' "$1" | base64 -D 2>/dev/null; }
d="$(dec "$db")"; g="$(dec "$gb")"
[ -n "$d" ] || d="$HOME"
case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/${d#\~/}";; esac
one() { printf '%s' "$1" | LC_ALL=C tr -d '\000-\010\013-\037\177' | tr '\t\r\n' '   ' | cut -c1-400; }
# -P: the physical path, which is the folder Claude reports (handoff headers match it)
if ! cd -P "$d" 2>/dev/null; then printf 'result\terr\tno-dir\ndir\t%s\ndone\t1\n' "$(one "$d")"; exit 0; fi
printf 'dir\t%s\n' "$(one "$PWD")"
command -v claude >/dev/null 2>&1 || { printf 'result\terr\tno-claude\ndone\t1\n'; exit 0; }
# A handoff package: written where this Mac's SessionStart hook (handoff-inject)
# loads it for this folder, with the header rewritten to this folder and this
# Mac's clock, plus a lasting copy the task points at, for a Mac without the hook.
P=""; unpend() { :; }
if [ -n "$pb" ]; then
  om="$(umask)"; umask 077  # the package is private; restored before claude starts
  sr="$HOME/.claude-swap-backup"; here="$(pwd -P)"
  mkdir -p "$sr/handoff-remote" "$sr/handoff-archive" 2>/dev/null || { printf 'result\terr\tno-state\ndone\t1\n'; exit 0; }
  command -v swap-guard >/dev/null 2>&1 && P="$(swap-guard path handoff --cwd "$here" 2>/dev/null)"
  if [ -z "$P" ]; then
    hh="$(printf '%s' "$here" | /usr/bin/shasum -a 256 | awk '{print $1}' | cut -c1-12)"
    P="$sr/handoff-pending-$hh.md"
  fi
  if [ -f "$P" ]; then
    c="$(sed -n '1s/.*created="\([0-9][0-9]*\)".*/\1/p' "$P" 2>/dev/null)"
    # The same package again (a retry after a timeout) may replace itself.
    same=0; [ "$(tail -n +2 "$P" 2>/dev/null)" = "$(dec "$pb" | tail -n +2)" ] && same=1
    if [ "$force" != 1 ] && [ "$same" != 1 ] && [ -n "$c" ] && [ $(( $(date +%s) - c )) -lt 600 ]; then
      printf 'result\terr\tpending-exists\ndone\t1\n'; exit 0
    fi
    hb="${P##*/}"; hb="${hb#handoff-pending-}"; hb="${hb%.md}"
    mv -f "$P" "$sr/handoff-archive/$(date -u +%Y%m%d-%H%M%S)-$hb-replaced.md" 2>/dev/null
  fi
  C="$sr/handoff-remote/$n-$(date +%Y%m%d-%H%M%S).md"
  { printf '<!-- handoff cwd="%s" created="%s" -->\n' "$here" "$(date +%s)"; dec "$pb" | tail -n +2; } > "$C" 2>/dev/null &&
    cp "$C" "$P.tmp.$$" 2>/dev/null && mv -f "$P.tmp.$$" "$P" 2>/dev/null ||
    { rm -f "$P.tmp.$$"; printf 'result\terr\tno-state\ndone\t1\n'; exit 0; }
  umask "$om"
  unpend() { rm -f "$P"; }  # the launch failed: don't leave it for an unrelated session here
  inj=0; grep -q 'handoff-inject' "$HOME/.claude/settings.json" 2>/dev/null && inj=1
  printf 'package\t%s\ninjector\t%s\n' "$(one "$C")" "$inj"
  g="Continue from the handoff package from another Mac. If it is not already in your context, read it first: $C"
fi
set -- -n "$n" --remote-control "$n"; [ -n "$g" ] && set -- "$@" "Task: $g"
out="$(claude --bg "$@" </dev/null 2>&1)"; rc=$?
case "$out" in
  *"not trusted"*) unpend; printf 'result\terr\tuntrusted\ndetail\t%s\ndone\t1\n' "$(one "$out")"; exit 0;;
esac
if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -qiE "unknown option.*(--bg|--background)"; then
  command -v tmux >/dev/null 2>&1 || { unpend; printf 'result\terr\tno-bg-no-tmux\ndone\t1\n'; exit 0; }
  # tmux session names can't hold "." or ":"; tmux 3.0+ runs a multi-word command
  # without a shell, so the goal stays one argument.
  if tmux new-session -d -s "$(printf '%s' "$n" | tr '.:' '__')" -c "$PWD" claude "$@" 2>/dev/null; then printf 'result\tok\ttmux\ndone\t1\n'
  else unpend; printf 'result\terr\ttmux-failed\ndone\t1\n'; fi
  exit 0
fi
if [ "$rc" -ne 0 ]; then unpend; printf 'result\terr\tfailed\ndetail\t%s\ndone\t1\n' "$(one "$out")"; exit 0; fi
printf 'result\tok\tbg\ndetail\t%s\ndone\t1\n' "$(one "$out")"
EOF
}

_remote_slug() { # free text → a session-name-safe slug (ASCII letters, digits, dashes); may be empty
  printf '%s' "$1" | LC_ALL=C tr -c 'A-Za-z0-9' '-' | LC_ALL=C tr -s '-' | sed 's/^-//; s/-$//' | cut -c1-"${2:-24}" | sed 's/-$//'
}

_remote_start() {
  local h="" dir="" name="" goal="" raw rc k v res="" code="" mode="" detail="" rdir="" fix="" tab hs gs sf
  local pkgf="$_RM_PKGF" force="$_RM_FORCE" pkg="" inj=""  # set only by _remote_handoff
  tab="$(printf '\t')"
  while [ $# -gt 0 ]; do
    case "$1" in
      --dir)  dir="${2:-}"; shift 2 || shift;;
      --name) name="${2:-}"; shift 2 || shift;;
      --) shift; goal="$*"; break;;
      *) if [ -z "$h" ]; then h="$1"; else goal="${goal:+$goal }$1"; fi; shift;;
    esac
  done
  _fleet_valid_host "$h" || { echo '{"error":"usage","detail":"swap-guard remote start <mac> [--dir <folder>] [--name <name>] [goal…]"}'; return 2; }
  if [ -n "$name" ]; then
    { _remote_valid_ref "$name" && ! _remote_is_id "$name"; } || { jq -cn '{error:"bad-name", detail:"use letters, digits, dot, dash or underscore (not 8 hex characters, which look like a session id)"}'; return 2; }
  else
    # <mac>-<goal words>; no usable words (e.g. a goal in Korean) → <mac>-<time>.
    hs="$(_remote_slug "${h%%.*}" 20)"; [ -n "$hs" ] || hs="remote"
    gs="$(_remote_slug "$goal" 24)"; [ -n "$gs" ] || gs="$(date +%m%d-%H%M%S)"
    name="$hs-$gs"
    _remote_valid_ref "$name" || name="remote-$(date +%m%d-%H%M%S)"
  fi
  sf="$(mktemp "${TMPDIR:-/tmp}/swap-guard-remote-script.XXXXXX")" || return 1
  _RM_SF="$sf"; _remote_trap
  _remote_start_script "$name" "$(printf '%s' "$dir" | base64 | tr -d '\n')" "$(printf '%s' "$goal" | base64 | tr -d '\n')" \
    "$( [ -n "$pkgf" ] && base64 < "$pkgf" | tr -d '\n')" "$force" > "$sf"
  raw="$(SWAP_FLEET_TIMEOUT="${SWAP_FLEET_TIMEOUT:-10}" _remote_ssh "${SWAP_REMOTE_DEADLINE:-45}" "$sf" "$h" bash -s)"; rc=$?
  rm -f "$sf"; _RM_SF=""
  if ! _fleet_complete "$raw"; then
    if [ "$rc" -eq 124 ]; then
      jq -cn --arg h "$h" --arg n "$name" '{error:"timeout", host:$h, name:$n, detail:"no complete answer before the deadline; the session may have started anyway", fix:("Check before retrying: swap-guard remote list " + $h)}'
    else
      jq -cn --arg h "$h" --arg n "$name" '{error:"unreachable", host:$h, name:$n, detail:"no answer over SSH", fix:("Check: ssh " + $h + " true   (Remote Login on, key added with ssh-copy-id)")}'
    fi
    return 1
  fi
  while IFS="$tab" read -r k v; do
    case "$k" in
      result) res="${v%%"$tab"*}"; code="${v#*"$tab"}";;
      detail) detail="$v";; dir) rdir="$v";;
      package) pkg="$v";; injector) inj="$v";;
    esac
  done <<EOF
$raw
EOF
  if [ "$res" = ok ]; then
    mode="$code"
    jq -cn --arg h "$h" --arg n "$name" --arg m "$mode" --arg d "$rdir" --arg x "$detail" --arg p "$_remote_path" \
      --arg pkg "$pkg" --arg inj "$inj" \
      '{started:true, host:$h, name:$n, mode:$m, dir:$d, detail:(if $x == "" then null else $x end)}
       + (if $pkg == "" then {} else {package:$pkg, injector:($inj == "1")} end)
       + {
        next:("Open claude.ai/code (or the Claude app) and pick \"" + $n + "\"."
          + (if $m == "tmux" then " It runs in tmux there: ssh -t " + $h + " '\''" + $p + "; tmux attach -t " + ($n | gsub("[.:]"; "_")) + "'\''" else "" end))}'
    return 0
  fi
  case "$code" in
    untrusted) fix="On $h, run claude once in ${rdir:-that folder} and accept the trust prompt, or pass --dir <a project you already use there>.";;
    no-dir)    fix="That folder does not exist on $h. Pass --dir <folder> (relative to its home, or ~/…).";;
    no-claude) fix="Claude Code is not installed (or not on PATH) on $h.";;
    no-bg-no-tmux) fix="Claude Code on $h is too old for --bg and tmux is missing: update Claude Code there (claude update).";;
    pending-exists) fix="A different handoff for that folder arrived on $h less than 10 minutes ago. Re-run with --force to replace it.";;
    no-state) fix="Could not write the package under ~/.claude-swap-backup on $h (disk full or permissions?).";;
    *) fix="Remote Control needs a /login login on $h (setup-tokens don't work); see the detail.";;
  esac
  jq -cn --arg h "$h" --arg n "$name" --arg c "${code:-failed}" --arg d "$rdir" --arg x "$detail" --arg f "$fix" \
    '{error:$c, host:$h, name:$n, dir:(if $d == "" then null else $d end), detail:(if $x == "" then null else $x end), fix:$f}'
  return 1
}

# _remote_agents <host> → the worker's background sessions as a JSON array, its
# strings stripped of control characters (they reach a terminal and the model).
# Status 124 deadline, 255 ssh failed, 3 the answer was not the JSON we expect.
_remote_agents() {
  local out rc
  out="$(_remote_ssh "${SWAP_REMOTE_DEADLINE:-20}" "" "$1" "$_remote_path; claude agents --json")"; rc=$?
  [ "$rc" -eq 124 ] || [ "$rc" -eq 255 ] && return "$rc"
  printf '%s' "$out" | jq -ce 'def clean: if type == "string" then gsub("[\u0000-\u001f\u007f]"; "") else . end;
    [.[]? | select(.kind == "background") | {id, name:(.name // null), state:(.state // .status // null), cwd:(.cwd // null), startedAt:(.startedAt // null)} | map_values(clean)]' 2>/dev/null || return 3
}

_remote_agents_error() { # <host> <status> → the JSON error for a failed _remote_agents
  case "$2" in
    124) jq -cn --arg h "$1" '{error:"timeout", host:$h, detail:"no answer before the deadline"}';;
    255) jq -cn --arg h "$1" '{error:"unreachable", host:$h, fix:("Check: ssh " + $h + " true")}';;
    *)   jq -cn --arg h "$1" '{error:"bad-answer", host:$h, detail:"claude agents --json gave no session list there", fix:"Is Claude Code installed and up to date on that Mac? (claude update)"}';;
  esac
}

# _remote_resolve <host> <id|name> → the session id: an exact id, else an exact name,
# else a unique part of a name. Status 1 none, 4 more than one (ids on stdout), else
# _remote_agents' status.
_remote_resolve() {
  local list rc ids
  list="$(_remote_agents "$1")"; rc=$?
  if [ "$rc" -ne 0 ]; then _remote_is_id "$2" && { printf '%s\n' "$2"; return 0; }; return "$rc"; fi
  ids="$(printf '%s' "$list" | jq -r --arg r "$2" '
      [.[] | select(.id == $r)] as $byid | [.[] | select(.name == $r)] as $exact
    | (if ($byid | length) > 0 then $byid elif ($exact | length) > 0 then $exact
       else [.[] | select((.name // "") | contains($r))] end) | .[].id')"
  [ -n "$ids" ] || { _remote_is_id "$2" && { printf '%s\n' "$2"; return 0; }; return 1; }
  if [ "$(printf '%s\n' "$ids" | wc -l | tr -d ' ')" -gt 1 ]; then printf '%s\n' "$ids"; return 4; fi
  _remote_is_id "$ids" || return 1
  printf '%s\n' "$ids"
}

# remote handoff <mac> [--dir D] [--name N] [--force] [--cwd C]: send this folder's
# outbox package (`swap-guard path remote-handoff`, written by /handoff <mac>) and
# start a session there that continues from it. One SSH call carries both, so a
# package never lands without a session or the other way round. The folder there
# defaults to the same place relative to home.
_remote_handoff() {
  local h="" cwd_opt="" cwd f title dir="" name="" hs ts created out rc force=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --cwd) cwd_opt="${2:-}"; shift 2 || shift;;
      --dir) dir="${2:-}"; shift 2 || shift;;
      --name) name="${2:-}"; shift 2 || shift;;
      --force) force=1; shift;;
      *) [ -z "$h" ] && h="$1"; shift;;
    esac
  done
  _fleet_valid_host "$h" || { echo '{"error":"usage","detail":"swap-guard remote handoff <mac> [--dir <folder>] [--name <name>] [--force]"}'; return 2; }
  cwd="$(resolve_cwd "$cwd_opt")"
  f="$STATE_ROOT/handoff-outbox-$(hash_cwd "$cwd").md"
  [ -f "$f" ] && [ ! -L "$f" ] || { jq -cn --arg f "$f" '{error:"no-package", detail:("write the package first: " + $f)}'; return 1; }
  head -n 1 "$f" | grep -Eq '^<!-- handoff cwd=".*" created="[0-9]+" -->[[:space:]]*$' ||
    { jq -cn --arg f "$f" '{error:"malformed-header", detail:("line 1 of " + $f + " must be the <!-- handoff cwd=... created=... --> header")}'; return 1; }
  # An old outbox is a leftover (the skill writes it right before sending), never this handoff.
  created="$(sed -n '1s/.*created="\([0-9][0-9]*\)".*/\1/p' "$f")"
  [ $(( $(date +%s) - created )) -le 3600 ] || { jq -cn --arg f "$f" '{error:"stale-package", detail:("the package at " + $f + " was written over an hour ago; write it again for this handoff")}'; return 1; }
  [ "$(LC_ALL=C wc -c < "$f" | tr -d ' ')" -le 65536 ] || { jq -cn '{error:"too-big", detail:"the package is over 64 KB; tighten it (aim for ~8,000 bytes)"}'; return 1; }
  if [ -z "$dir" ]; then
    # shellcheck disable=SC2088  # a literal ~: the worker expands it to its own home
    case "$cwd" in "$HOME") dir="~";; "$HOME"/*) dir="~/${cwd#"$HOME"/}";; *) dir="$cwd";; esac
  fi
  if [ -z "$name" ]; then
    title="$(sed -n '2s/^#[[:space:]]*//p' "$f" | sed 's/^Handoff[[:space:]]*[^A-Za-z0-9]*[[:space:]]*//')"
    hs="$(_remote_slug "${h%%.*}" 20)"; [ -n "$hs" ] || hs="remote"
    ts="$(_remote_slug "$title" 24)"; [ -n "$ts" ] || ts="$(date +%m%d-%H%M%S)"
    name="$hs-$ts"
  fi
  out="$(_RM_PKGF="$f" _RM_FORCE="$force" _remote_start "$h" --dir "$dir" --name "$name")"; rc=$?
  printf '%s\n' "$out"
  # Sent and started: the outbox is done. On a failure it stays, so a retry needs no rewrite.
  [ "$rc" -eq 0 ] && rm -f "$f"
  return "$rc"
}

cmd_remote() {
  local sub="${1:-}" h ref id out rc text=0; [ $# -gt 0 ] && shift
  case "$sub" in
    start) _remote_start "$@"; exit $?;;
    handoff) _remote_handoff "$@"; exit $?;;
    list)
      h="${1:-}"; [ "${2:-}" = "--text" ] && text=1
      _fleet_valid_host "$h" || { echo '{"error":"usage","detail":"swap-guard remote list <mac> [--text]"}'; exit 2; }
      out="$(_remote_agents "$h")" || { _remote_agents_error "$h" "$?"; exit 1; }
      if [ "$text" -eq 1 ]; then
        printf '%s' "$out" | jq -r --arg h "$h" 'if length == 0 then "no background sessions on \($h)" else .[] | "\(.id)  \(.name // "-")  \(.state // "?")  \(.cwd // "")" end'
      else printf '%s\n' "$out"; fi
      exit 0;;
    logs|stop|attach)
      h="${1:-}"; ref="${2:-}"
      { _fleet_valid_host "$h" && _remote_valid_ref "$ref"; } || { jq -cn --arg s "$sub" '{error:"usage", detail:("swap-guard remote " + $s + " <mac> <id|name>")}'; exit 2; }
      if [ "$sub" = attach ]; then
        if [ ! -t 0 ] || [ ! -t 1 ]; then
          jq -cn --arg h "$h" --arg r "$ref" --arg p "$_remote_path" '{error:"needs-terminal", command:("ssh -t " + $h + " '\''" + $p + "; claude attach " + $r + "'\''")}'; exit 1
        fi
        exec ssh -t -o ConnectTimeout="${SWAP_FLEET_TIMEOUT:-5}" -- "$h" "$_remote_path; claude attach '$ref'"
      fi
      if [ "$sub" = logs ]; then
        out="$(_remote_ssh "${SWAP_REMOTE_DEADLINE:-20}" "" "$h" "$_remote_path; claude logs '$ref'")"; rc=$?
        printf '%s\n' "$out" | LC_ALL=C tr -d '\000-\010\013-\037\177'
        [ "$rc" -eq 124 ] && echo "(stopped waiting after ${SWAP_REMOTE_DEADLINE:-20} s)" >&2
        exit "$rc"
      fi
      id="$(_remote_resolve "$h" "$ref")"; rc=$?
      case "$rc" in
        0) :;;
        1) jq -cn --arg h "$h" --arg r "$ref" '{error:"no-such-session", host:$h, ref:$r, fix:("See: swap-guard remote list " + $h)}'; exit 1;;
        4) jq -cn --arg h "$h" --arg r "$ref" --arg ids "$id" '{error:"ambiguous", host:$h, ref:$r, ids:($ids | split("\n") | map(select(. != ""))), fix:"Stop it by id (swap-guard remote list shows them)."}'; exit 1;;
        *) _remote_agents_error "$h" "$rc"; exit 1;;
      esac
      out="$(_remote_ssh "${SWAP_REMOTE_DEADLINE:-20}" "" "$h" "$_remote_path; claude stop '$id' 2>&1")"; rc=$?
      if [ "$rc" -eq 0 ]; then
        jq -cn --arg h "$h" --arg i "$id" '{stopped:true, host:$h, id:$i, detail:("Claude Code keeps its conversation; resume with: swap-guard remote attach " + $h + " " + $i)}'
      else
        jq -cn --arg h "$h" --arg i "$id" --argjson rc "$rc" --arg x "$(printf '%s' "$out" | LC_ALL=C tr -d '\000-\010\013-\037\177' | tail -c 300)" '{error:(if $rc == 124 then "timeout" else "stop-failed" end), host:$h, id:$i, detail:$x}'; exit 1
      fi
      exit 0;;
    *) echo "usage: swap-guard remote start <mac> [--dir D] [--name N] [goal…] | handoff <mac> [--dir D] [--name N] [--force] | list <mac> [--text] | logs|stop|attach <mac> <id|name>" >&2; exit 2;;
  esac
}

# cmd_artifacts — snapshot every claude.ai artifact this session (and its subagents /
# workflows) published or touched, so a handoff can carry them across sessions and
# accounts. Source of truth is the transcript's structured toolUseResult
# ({url, artifact_id, title, path, ...}) — never the model's recollection. Sources
# live in the session-scoped /tmp scratchpad, so they are COPIED to a durable dir.
# Artifacts are owned server-side by the account that published them; the manifest
# records the account active at collection time as `account`.
cmd_artifacts() {
  local sid="" cwd="" who flat tdir out_dir manifest tmp acct now
  while [ $# -gt 0 ]; do
    case "$1" in
      --session) sid="${2:-}"; shift 2 || shift;;
      --cwd)     cwd="${2:-}"; shift 2 || shift;;
      *) shift;;
    esac
  done
  if [ -z "$sid" ] || [ -z "$cwd" ]; then
    who="$(whoami_json 2>/dev/null)" || who=""
    [ -n "$sid" ] || sid="$(printf '%s' "$who" | jq -r '.sessionId // empty' 2>/dev/null)"
    [ -n "$cwd" ] || cwd="$(printf '%s' "$who" | jq -r '.cwd // empty' 2>/dev/null)"
  fi
  [ -n "$cwd" ] || cwd="$PWD"
  case "$sid" in ''|*/*|*..*) printf '%s\n' '{"error":"no-session-id"}'; exit 1;; esac
  flat="$(printf '%s' "$cwd" | sed 's/[^A-Za-z0-9]/-/g')"
  tdir="$HOME/.claude/projects/$flat"
  [ -f "$tdir/$sid.jsonl" ] || { printf '{"error":"no-transcript","path":"%s"}\n' "$tdir/$sid.jsonl"; exit 1; }

  out_dir="$ARTIFACTS_DIR/$sid"
  mkdir -p "$out_dir" || exit 1
  acct="$(jq -r '.oauthAccount.emailAddress // empty' "$HOME/.claude.json" 2>/dev/null)"
  now="$(date +%s)"

  # One record per artifact_id (last touch wins). Join each result with its tool_use
  # input to recover supporting `files` (map form: pub -> src | {from}).
  tmp="$(mktemp)"
  { printf '%s\n' "$tdir/$sid.jsonl"
    [ -d "$tdir/$sid" ] && find "$tdir/$sid" -name '*.jsonl' -type f
  } | while IFS= read -r f; do
    jq -c '
      if .type == "assistant" then
        (.message.content[]? | select(.type == "tool_use" and .name == "Artifact")
         | {k: "use", id, files: (.input.files // null), file_path: (.input.file_path // null)})
      elif .type == "user" and (.toolUseResult | type) == "object"
           and ((.toolUseResult.url // "") | test("^https://claude\\.ai/(code/)?artifact/")) then
        {k: "res", id: (.message.content[0].tool_use_id // null), ts: (.timestamp // ""),
         r: (.toolUseResult | {url, artifact_id, title, path, audience, capabilities})}
      else empty end' "$f" 2>/dev/null
  done > "$tmp"

  jq -s '
    (map(select(.k == "use")) | map({key: (.id // ""), value: .}) | from_entries) as $uses
    | map(select(.k == "res" and (.r.url // "") != ""))
    | sort_by(.ts)
    | map(. as $x | $x.r + {ts: $x.ts,
          path: ($x.r.path // $uses[$x.id // ""].file_path // null),
          files: ($uses[$x.id // ""].files // null)})
    | map(.artifact_id = (.artifact_id // (.url | sub(".*/artifact/"; ""))))
    | group_by(.artifact_id)
    | map(reduce .[] as $e ({}; . + ($e | with_entries(select(.value != null)))))
    | sort_by(.ts)' "$tmp" > "$tmp.recs" 2>/dev/null || printf '[]' > "$tmp.recs"
  rm -f "$tmp"
  # Per-artifact owner from the registry (the account active when it was published);
  # null when the artifact predates the registry hook.
  _art_catalog > "$tmp.cat"
  jq --slurpfile catf "$tmp.cat" '$catf[0] as $cat | map(. as $r | (first($cat[] | select(.artifact_id == $r.artifact_id)) // {}) as $c
      | . + {owner: ($c.account // null), ownerAlias: ($c.alias // null)})' "$tmp.recs" > "$tmp.recs2" 2>/dev/null &&
    mv "$tmp.recs2" "$tmp.recs"
  rm -f "$tmp.cat"

  # Copy sources: page -> <id>/<basename>; supporting files -> <id>/<published path>.
  local n i rec aid src dst saved files_saved pub fsrc
  n="$(jq 'length' "$tmp.recs")"
  printf '[]' > "$tmp.out"
  i=0
  while [ "$i" -lt "$n" ]; do
    rec="$(jq -c ".[$i]" "$tmp.recs")"
    aid="$(printf '%s' "$rec" | jq -r '.artifact_id' | tr -cd 'A-Za-z0-9_-')"
    src="$(printf '%s' "$rec" | jq -r '.path // empty')"
    saved="null"; files_saved="{}"
    if [ -n "$aid" ] && [ -n "$src" ] && [ -f "$src" ]; then
      mkdir -p "$out_dir/$aid"
      dst="$out_dir/$aid/${src##*/}"
      _store_copy "$src" "$dst" && saved="$(jq -Rn --arg d "$dst" '$d')"
      while IFS=$'\t' read -r pub fsrc; do
        [ -n "$pub" ] && [ -n "$fsrc" ] || continue
        case "$pub" in /*|*..*) continue;; esac
        case "$fsrc" in /*) :;; *) fsrc="$cwd/$fsrc";; esac
        [ -f "$fsrc" ] || continue
        _store_copy "$fsrc" "$out_dir/$aid/$pub" &&
          files_saved="$(printf '%s' "$files_saved" | jq -c --arg k "$pub" --arg v "$out_dir/$aid/$pub" '. + {($k): $v}')"
      done < <(printf '%s' "$rec" | jq -r '
        (.files // {}) | if type == "object" then to_entries[]
          | [.key, (if (.value | type) == "string" then .value
                    elif (.value | type) == "object" then (.value.from // empty)
                    else empty end)] | @tsv else empty end' 2>/dev/null)
    fi
    jq --argjson r "$rec" --argjson s "$saved" --argjson fs "$files_saved" \
      '. + [$r + {saved: $s, savedFiles: $fs} | del(.files)]' "$tmp.out" > "$tmp.out2" && mv "$tmp.out2" "$tmp.out"
    i=$((i + 1))
  done

  manifest="$out_dir/manifest.json"
  jq -n --arg sid "$sid" --arg cwd "$cwd" --arg acct "$acct" --argjson now "$now" \
     --slurpfile a "$tmp.out" \
     '{sessionId: $sid, cwd: $cwd, account: $acct, collected: $now, artifacts: $a[0]}' > "$manifest.tmp" &&
    mv "$manifest.tmp" "$manifest"
  rm -f "$tmp.recs" "$tmp.out"
  # Keep the store bounded: drop snapshots untouched for 30+ days.
  find "$ARTIFACTS_DIR" -mindepth 1 -maxdepth 1 -type d -mtime +30 -exec rm -rf {} + 2>/dev/null
  jq -c --arg m "$manifest" '. + {manifest: $m}' "$manifest"
  exit 0
}

# --- shared: usage windows --------------------------------------------------------
# jq helpers over cswap's ISO timestamps ("...+00:00" with microseconds).
JQ_EPOCH='def ep: if type == "string" and test("(Z|\\+00:00)$")
  then (sub("\\.[0-9]+"; "") | sub("\\+00:00$"; "Z") | try (fromdateiso8601 | floor) catch null)
  else null end;'

# --- ratelimit: instant swap when a usage cap stops a session ---------------------
# Wired as a StopFailure hook (matcher rate_limit, asyncRewake). StopFailure itself
# is observe-only, so this hook does the switch; exiting 2 under asyncRewake wakes
# the stopped session with the stderr text so it can carry on.
# Only usage CAPS swap ("You've reached your Fable limit…"); a transient 429
# throttle ("Rate limit reached") is logged and ignored.
RL_COOLDOWN=90      # sessions that hit the cap within this window reuse one swap
RL_WAKE_GAP=600     # wake a given session at most once per this many seconds (loop guard)
RL_LAST="$STATE_ROOT/ratelimit-last.json"
INSTANT_OFF="$STATE_ROOT/instant-swap.off"

_rl_is_cap() {
  case "$1" in
    *"reached your"*|*"usage limit"*|*"/usage-credits"*|*"/upgrade"*|*"/extra-usage"*|*"resets "*) return 0;;
  esac
  return 1
}

_rl_model() { # the model a cap message names ("reached your Fable limit" -> Fable), else ""
  local m lc
  m="$(printf '%s' "$1" | sed -n "s/.*reached your \([A-Za-z0-9][A-Za-z0-9._-]*\) limit.*/\1/p" | head -n 1)"
  lc="$(printf '%s' "$m" | tr '[:upper:]' '[:lower:]')"
  case "$lc" in usage|weekly|session|daily|5-hour|five-hour|hourly) m="";; esac
  printf '%s\n' "$m"
}

# _rl_pick <cswap-list-json> <model> <now> — best OTHER account with room, as JSON.
# Room = every relevant window under 100% (a window whose reset passed counts as 0).
# Skips disabled accounts, dead tokens, API-key accounts (those bill per use) and
# logins within hours of their end date (_logins_blocked).
RL_CAPPED="$STATE_ROOT/ratelimit-capped.json"   # [{slot, model, until}] — accounts that hit a cap
_rl_pick() {
  local capped; capped="$(jq -c 'if type == "array" then . else [] end' "$RL_CAPPED" 2>/dev/null)" || capped="[]"
  [ -n "$capped" ] || capped="[]"
  local blocked; blocked="$(_logins_blocked)"
  printf '%s' "$1" | jq -c --arg model "$2" --argjson now "$3" --argjson capped "$capped" --argjson blocked "$blocked" "$JQ_EPOCH"'
    def pct($w): if $w == null then 0
      elif (($w.resetsAt // "") | ep) as $r | ($r != null and $r <= $now) then 0
      else ($w.pct // 0) end;
    (.activeAccountNumber // null) as $act
    | [.accounts[]?
       | select(.number != $act and ((.disabled // false) | not))
       | (.number | tostring) as $n
       | select($blocked | index($n) | not)
       | select([$capped[] | select(.slot == $n and .until > $now
                 and (.model == "" or (.model | ascii_downcase) == ($model | ascii_downcase)))] | length == 0)
       | select(.usageStatus as $s | ["ok", "token_expired", "unavailable"] | index($s))
       | (.usage // .lastGoodUsage) as $u | select($u != null)
       | {number, email, alias, h5: pct($u.fiveHour), d7: pct($u.sevenDay),
          m: (if $model == "" then 0
              else pct([$u.scoped[]? | select((.name | ascii_downcase) == ($model | ascii_downcase))] | first)
              end)}
       | .score = ([.h5, .d7, .m] | max) | select(.score < 100)]
    # Ties (same binding window) go to the account with more of the capped model left,
    # then more of its week.
    | sort_by([.score, .m, .d7, .h5]) | first // empty' 2>/dev/null
}

_label_for() { # _label_for <slot> — alias, else the email's local part, else "account N"
  local l
  l="$(jq -r --arg n "$1" '.accounts[$n] | (.alias // ((.email // "") | split("@")[0])) // empty' \
    "$STATE_ROOT/sequence.json" 2>/dev/null)"
  printf '%s\n' "${l:-account $1}"
}

_rl_art_note() { # _rl_art_note <sid> <from slot> <to slot> — " <artifact note>" for a wake
  # text, or nothing when this session left no artifacts behind
  local n
  [ -n "${1:-}" ] && [ -n "${3:-}" ] || return 0
  n="$(_art_switch_note "$1" "$(_label_for "$2")" "$(_label_for "$3")" \
    "$(jq -r --arg n "$3" '.accounts[$n].email // empty' "$STATE_ROOT/sequence.json" 2>/dev/null)")" && printf ' %s' "$n"
  return 0
}

_rl_wake() { # _rl_wake <sid> <text> [<to slot>] — print text for Claude and return 0 if this session may be woken
  local sid="$1" f last now
  [ -n "$sid" ] || return 1
  now="$(date +%s)"; f="$CTX_DIR/$sid.rlwake"
  last="$(cat "$f" 2>/dev/null)"
  case "$last" in ''|*[!0-9]*) last=0;; esac
  [ $(( now - last )) -ge "$RL_WAKE_GAP" ] || return 1
  mkdir -p "$CTX_DIR" 2>/dev/null && printf '%s\n' "$now" > "$f"
  printf '%s\n' "$2" >&2
  # The session now knows which account it is on: its next prompt needn't repeat it.
  [ -n "${3:-}" ] && printf '%s\n' "$3" > "$CTX_DIR/$sid.acct"
  return 0
}

cmd_ratelimit() {
  local notify=1 print=0 dry=0 input sid err msg model now out list pick to from to_l from_l lock i sw rc wake_txt
  while [ $# -gt 0 ]; do
    case "$1" in --no-notify) notify=0;; --print) print=1;; --dry-run) dry=1;; esac
    shift
  done
  input="$(cat 2>/dev/null)" || input=""
  command -v jq >/dev/null 2>&1 || exit 0
  sid="$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null | tr -cd 'A-Za-z0-9_-')"
  err="$(printf '%s' "$input" | jq -r '.error // empty' 2>/dev/null)"
  msg="$(printf '%s' "$input" | jq -r '[.last_assistant_message // empty, .error_details // empty] | join(" ")' 2>/dev/null)"
  now="$(date +%s)"
  _rl_out() { # _rl_out <json> — log; echo only with --print (hook stdout must stay empty)
    [ "$dry" -eq 1 ] || _auto_log "$1"; [ "$print" -eq 1 ] && printf '%s\n' "$1"; return 0
  }
  [ "$err" = rate_limit ] || exit 0
  # A `swap-guard token run` job bills its token's account, not the one logged in
  # here: its cap says nothing about the live account, so never switch (or move other
  # Macs) on it.
  if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
    _rl_out "$(jq -cn --argjson ts "$now" '{ts:($ts|todate), source:"ratelimit", action:"skipped", reason:"token-session"}')"; exit 0
  fi
  if [ -f "$INSTANT_OFF" ]; then
    _rl_out "$(jq -cn --argjson ts "$now" '{ts:($ts|todate), source:"ratelimit", action:"skipped", reason:"instant-swap-off"}')"; exit 0
  fi
  if ! _rl_is_cap "$msg"; then
    _rl_out "$(jq -cn --argjson ts "$now" --arg m "$(printf '%s' "$msg" | cut -c1-160)" \
      '{ts:($ts|todate), source:"ratelimit", action:"ignored", reason:"transient-429", message:$m}')"; exit 0
  fi
  model="$(_rl_model "$msg")"
  command -v cswap >/dev/null 2>&1 || {
    _rl_out "$(jq -cn --argjson ts "$now" '{ts:($ts|todate), source:"ratelimit", action:"error", detail:"cswap not found"}')"; exit 0; }

  # One swap for a burst: several sessions on the capped account fail together.
  mkdir -p "$STATE_ROOT" "$CTX_DIR" 2>/dev/null
  lock="$STATE_ROOT/ratelimit.lock"
  i=0
  until mkdir "$lock" 2>/dev/null; do
    if [ -n "$(find "$lock" -maxdepth 0 -mmin +2 2>/dev/null)" ]; then rmdir "$lock" 2>/dev/null; continue; fi
    i=$((i + 1))
    if [ "$i" -ge 60 ]; then
      # Another hook held the lock the whole time (slow cswap): if it switched, wake onto that.
      local lt; lt="$(jq -r '.ts // 0' "$RL_LAST" 2>/dev/null)"; case "$lt" in ''|*[!0-9]*) lt=0;; esac
      [ $(( $(date +%s) - lt )) -lt "$RL_COOLDOWN" ] && [ "$dry" -eq 0 ] &&
        _rl_wake "$sid" "overclaude: the ${model:-usage} limit stopped this session; the account was just switched to $(_label_for "$(jq -r '.to // empty' "$RL_LAST" 2>/dev/null)"). Continue the task from where it stopped. If the same limit error comes back, stop and tell the user instead of retrying.$(_rl_art_note "$sid" "$(jq -r '.from // empty' "$RL_LAST" 2>/dev/null)" "$(jq -r '.to // empty' "$RL_LAST" 2>/dev/null)")" "$(jq -r '.to // empty' "$RL_LAST" 2>/dev/null)" && exit 2
      exit 0
    fi
    sleep 1
  done
  trap 'rmdir "$lock" 2>/dev/null' EXIT

  local last_ts last_to
  last_ts="$(jq -r '.ts // 0' "$RL_LAST" 2>/dev/null)"; case "$last_ts" in ''|*[!0-9]*) last_ts=0;; esac
  if [ $(( now - last_ts )) -lt "$RL_COOLDOWN" ]; then
    last_to="$(jq -r '.to // empty' "$RL_LAST" 2>/dev/null)"
    _rl_out "$(jq -cn --argjson ts "$now" --arg to "$last_to" --arg s "$sid" \
      '{ts:($ts|todate), source:"ratelimit", action:"reused", to:$to, session:$s}')"
    rmdir "$lock" 2>/dev/null; trap - EXIT
    [ "$dry" -eq 1 ] && exit 0
    _rl_wake "$sid" "overclaude: the ${model:-usage} limit stopped this session; the account was already switched to $(_label_for "$last_to") moments ago. Continue the task from where it stopped. If the same limit error comes back, stop and tell the user instead of retrying.$(_rl_art_note "$sid" "$(jq -r '.from // empty' "$RL_LAST" 2>/dev/null)" "$last_to")" "$last_to" && exit 2
    exit 0
  fi

  list="$(cswap list --json 2>/dev/null)"
  from="$(printf '%s' "$list" | jq -r '.activeAccountNumber // empty' 2>/dev/null)"
  from_l="$(_label_for "$from")"
  pick="$(_rl_pick "$list" "$model" "$now")"
  if [ -z "$pick" ]; then
    _rl_out "$(jq -cn --argjson ts "$now" --arg m "$model" --arg f "$from" \
      '{ts:($ts|todate), source:"ratelimit", action:"none", reason:"no-account-with-room", model:$m, from:$f}')"
    [ "$notify" -eq 1 ] && [ "$dry" -eq 0 ] &&
      _auto_notify "${model:-Usage} limit on $from_l, and no other account has room$(command -v codex >/dev/null 2>&1 && echo ' — /handoff codex continues in Codex' || echo ' (or they need: swap-guard login)')."
    exit 0
  fi
  to="$(printf '%s' "$pick" | jq -r '.number')"
  to_l="$(_label_for "$to")"
  if [ "$dry" -eq 1 ]; then
    _rl_out "$(printf '%s' "$pick" | jq -c --argjson ts "$now" --arg m "$model" --arg f "$from" \
      '{ts:($ts|todate), source:"ratelimit", action:"would-switch", model:$m, from:$f, to:(.number|tostring), score}')"
    exit 0
  fi
  sw="$(cswap switch "$to" --json 2>/dev/null)"; rc=$?
  # cswap exits 0 for a no-op too ({"switched": false, "reason": "already-active"}),
  # e.g. when its idea of the active account is stale — that is not a switch.
  if [ "$rc" -eq 0 ] && [ "$(printf '%s' "$sw" | jq -r 'if .switched == false then "no" else "yes" end' 2>/dev/null)" = no ]; then
    rc=4; sw="no switch: $(printf '%s' "$sw" | jq -r '.reason // "unknown"' 2>/dev/null)"
  fi
  if [ "$rc" -ne 0 ]; then
    _rl_out "$(jq -cn --argjson ts "$now" --arg to "$to" --argjson rc "$rc" --arg d "$(printf '%s' "$sw" | tail -c 300)" \
      '{ts:($ts|todate), source:"ratelimit", action:"error", to:$to, rc:$rc, detail:$d}')"
    [ "$notify" -eq 1 ] && _auto_notify "${model:-Usage} limit on $from_l — switching to $to_l failed (see auto.log)."
    exit 0
  fi
  jq -cn --argjson ts "$now" --arg f "$from" --arg to "$to" --arg m "$model" \
    '{ts:$ts, from:$f, to:$to, model:$m}' > "$RL_LAST.tmp.$$" && mv "$RL_LAST.tmp.$$" "$RL_LAST"
  # Remember that <from> is capped on <model> until its window resets (5 h if unknown),
  # so a later cap elsewhere never hops back onto it on stale cached numbers.
  printf '%s' "$list" | jq -c --arg f "$from" --arg m "$model" --argjson now "$now" \
      --argjson prev "$(jq -c 'if type == "array" then . else [] end' "$RL_CAPPED" 2>/dev/null || echo '[]')" "$JQ_EPOCH"'
    (first(.accounts[]? | select((.number | tostring) == $f)) // {}) as $a | ($a.usage // $a.lastGoodUsage // {}) as $u
    | (if $m == "" then ([$u.fiveHour, $u.sevenDay] | map(select(. != null and (.pct // 0) >= 100))[0])
       else ([$u.scoped[]? | select((.name | ascii_downcase) == ($m | ascii_downcase))] | first) end) as $w
    | ((($w // {}).resetsAt // "") | ep) as $r
    | [$prev[] | select(.until > $now and (.slot != $f or .model != $m))]
      + [{slot: $f, model: $m, until: (if $r != null and $r > $now then $r else $now + 18000 end)}]' \
    > "$RL_CAPPED.tmp.$$" 2>/dev/null && mv "$RL_CAPPED.tmp.$$" "$RL_CAPPED"
  _rl_out "$(printf '%s' "$pick" | jq -c --argjson ts "$now" --arg m "$model" --arg f "$from" --arg s "$sid" \
    '{ts:($ts|todate), source:"ratelimit", action:"switched", model:$m, from:$f, to:(.number|tostring), score, session:$s}')"
  rmdir "$lock" 2>/dev/null; trap - EXIT
  [ "$notify" -eq 1 ] &&
    _auto_notify "${model:-Usage} limit on $from_l → switched to $to_l. If the session does not resume by itself, send any message."
  # Other Macs on the capped account follow (background: SSH must never delay the wake).
  if [ -n "$(_fleet_hosts)" ] && [ -z "${SWAP_NO_FLEET_SWAP:-}" ] && [ ! -f "$FLEET_SWAP_OFF" ]; then
    local from_e; from_e="$(printf '%s' "$list" | jq -r --arg f "$from" 'first(.accounts[]? | select((.number | tostring) == $f)) | .email // empty' 2>/dev/null)"
    [ -n "$from_e" ] && ( "$0" fleet swap --from "$from_e" --model "$model" $([ "$notify" -eq 1 ] && echo --notify) >/dev/null 2>&1 & )
  fi
  wake_txt="overclaude: the ${model:-usage} limit stopped this session, so the account was switched from $from_l to $to_l (now $(printf '%s' "$pick" | jq -r --arg m "$model" '"5h \(.h5)% · week \(.d7)%" + (if $m != "" then " · \($m) \(.m)%" else "" end)')). Continue the task from where it stopped. If the same limit error comes back, stop and tell the user instead of retrying."
  local to_slot; to_slot="$(printf '%s' "$pick" | jq -r '.number // empty')"
  wake_txt="$wake_txt$(_rl_art_note "$sid" "$from" "$to_slot")"
  _rl_wake "$sid" "$wake_txt" "$to_slot" && exit 2
  exit 0
}

# --- server-retry: resume after a temporary server error (opt-in) ----------------
# Wired as a StopFailure hook (matcher server_error|overloaded, asyncRewake): it
# waits, then exits 2 so Claude Code wakes the session to continue. Off by default.
# Capped hard: an uncapped rewake retries about once a second (measured, plan S5),
# so at most SR_MAX tries per session per hour, waiting 30 s, 2 min, then 5 min.
SR_ON="$STATE_ROOT/server-retry.on"
SR_MAX=3

cmd_server_retry() {
  local input sid err f now n=0 t delays d keep="" tp m0
  case "${1:-}" in
    on)  mkdir -p "$STATE_ROOT" && touch "$SR_ON"; jq -cn '{serverRetry:true}'; exit 0;;
    off) rm -f "$SR_ON"; jq -cn '{serverRetry:false}'; exit 0;;
    status|"") jq -cn --argjson on "$([ -f "$SR_ON" ] && echo true || echo false)" '{serverRetry:$on}'; exit 0;;
    --hook) :;;
    *) echo "usage: swap-guard server-retry on|off|status" >&2; exit 1;;
  esac
  input="$(cat 2>/dev/null)" || input=""
  [ -f "$SR_ON" ] || exit 0
  command -v jq >/dev/null 2>&1 || exit 0
  err="$(printf '%s' "$input" | jq -r '.error // empty' 2>/dev/null)"
  case "$err" in server_error|overloaded) :;; *) exit 0;; esac
  sid="$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null | tr -cd 'A-Za-z0-9_-')"
  [ -n "$sid" ] || exit 0
  mkdir -p "$CTX_DIR" 2>/dev/null || exit 0
  f="$CTX_DIR/$sid.retry"; now="$(date +%s)"
  # Tries in the last hour (one epoch per line; older lines are dropped).
  if [ -f "$f" ]; then
    while IFS= read -r t; do
      case "$t" in ''|*[!0-9]*) continue;; esac
      [ $(( now - t )) -lt 3600 ] || continue
      keep="${keep}${t}
"; n=$((n + 1))
    done < "$f"
  fi
  if [ "$n" -ge "$SR_MAX" ]; then
    _auto_log "$(jq -cn --argjson ts "$now" --arg e "$err" --arg s "$sid" '{ts:($ts|todate), source:"server-retry", action:"gave-up", error:$e, session:$s}')"
    exit 0
  fi
  printf '%s%s\n' "$keep" "$now" > "$f"
  delays="${SWAP_RETRY_DELAYS:-30 120 300}"
  # shellcheck disable=SC2086
  set -- $delays
  shift "$n" 2>/dev/null || true
  d="${1:-300}"; case "$d" in ''|*[!0-9]*) d=300;; esac
  _auto_log "$(jq -cn --argjson ts "$now" --arg e "$err" --arg s "$sid" --argjson try "$((n + 1))" --argjson d "$d" '{ts:($ts|todate), source:"server-retry", action:"retrying", error:$e, try:$try, waitSec:$d, session:$s}')"
  tp="$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null)"
  # Baseline: the later of the transcript's last write and now plus a grace period,
  # so Claude Code's own writes right after the failure (the error entry, hook
  # records) never count as the user moving on.
  m0="$(stat -f %m "$tp" 2>/dev/null || echo 0)"
  t="$(( now + ${SWAP_RETRY_GRACE:-15} ))"; [ "$t" -gt "$m0" ] && m0="$t"
  sleep "$d"
  # The transcript grew while we waited: the user (or something else) moved the
  # session on, and a late "continue" would land in the middle of new work.
  if [ -n "$tp" ] && [ "$(stat -f %m "$tp" 2>/dev/null || echo 0)" -gt "$m0" ]; then
    _auto_log "$(jq -cn --argjson ts "$(date +%s)" --arg s "$sid" '{ts:($ts|todate), source:"server-retry", action:"skipped", reason:"session-moved-on", session:$s}')"
    exit 0
  fi
  # Only the last try says to stop: earlier ones are retried by this hook, not by Claude.
  local last=""
  [ $((n + 1)) -ge "$SR_MAX" ] && last=" This is the last automatic retry: if it fails again, stop and tell the user."
  printf 'overclaude: the last turn ended on a temporary server error (%s); this is automatic retry %s of %s. Continue the task from where it stopped.%s\n' "$err" "$((n + 1))" "$SR_MAX" "$last" >&2
  exit 2
}

# --- loop-guard: notice the same tool call failing the same way (opt-in) ----------
# Wired as a PostToolUseFailure hook (all tools). A failure's signature is the tool
# name, its input (minus the free-text description) and the error with digits and
# whitespace folded, so timings and line numbers don't hide a repeat. Successes in
# between don't reset the count (edit → same test fails again is the classic loop);
# a different failure does. At SWAP_LOOP_MAX repeats (default 3) Claude is told to rethink; at twice
# that, to stop and tell the user. Silent otherwise. Off by default.
LG_ON="$STATE_ROOT/loop-guard.on"

cmd_loop_guard() {
  local input sid aid f raw sig tool prev="" n=0 ts=0 max now
  case "${1:-}" in
    on)  mkdir -p "$STATE_ROOT" && touch "$LG_ON"; jq -cn '{loopGuard:true}'; exit 0;;
    off) rm -f "$LG_ON"; jq -cn '{loopGuard:false}'; exit 0;;
    status|"") jq -cn --argjson on "$([ -f "$LG_ON" ] && echo true || echo false)" '{loopGuard:$on}'; exit 0;;
    --hook) :;;
    *) echo "usage: swap-guard loop-guard on|off|status" >&2; exit 1;;
  esac
  input="$(cat 2>/dev/null)" || input=""
  [ -f "$LG_ON" ] || exit 0
  command -v jq >/dev/null 2>&1 || exit 0
  [ "$(printf '%s' "$input" | jq -r '.is_interrupt // false' 2>/dev/null)" = "true" ] && exit 0
  sid="$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null | tr -cd 'A-Za-z0-9_-')"
  [ -n "$sid" ] || exit 0
  # Hooks inside a subagent carry agent_id (session_id appears to be the parent's,
  # per the docs' SubagentStop example), so key on both: each agent keeps its own
  # count, and parallel agents neither reset nor trip each other's.
  aid="$(printf '%s' "$input" | jq -r '.agent_id // empty' 2>/dev/null | tr -cd 'A-Za-z0-9_-' | cut -c1-64)"
  tool="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null | tr -cd 'A-Za-z0-9_.:-' | cut -c1-80)"
  [ -n "$tool" ] || exit 0
  raw="$(printf '%s' "$input" | jq -r '
    [ .tool_name,
      ((.tool_input // {}) | if type == "object" then del(.description) | to_entries | sort_by(.key) | from_entries else . end | tojson),
      ((.error // "") | tostring | gsub("[0-9]+"; "0") | gsub("\\s+"; " ") | .[0:2000]) ] | join("\u001f")' 2>/dev/null)" || exit 0
  [ -n "$raw" ] || exit 0
  sig="$(printf '%s' "$raw" | cksum | tr -s ' ' '-')"
  mkdir -p "$CTX_DIR" 2>/dev/null || exit 0
  f="$CTX_DIR/$sid${aid:+.$aid}.loop"; now="$(date +%s)"
  { read -r prev n ts < "$f"; } 2>/dev/null
  case "$n" in ''|*[!0-9]*) n=0;; esac
  case "$ts" in ''|*[!0-9]*) ts=0;; esac
  # A repeat counts only within 30 min of the last one: the same failure coming
  # back much later is not a loop.
  if [ "$prev" = "$sig" ] && [ $(( now - ts )) -lt 1800 ]; then n=$((n + 1)); else n=1; fi
  # Concurrent failures in one agent can lose an update (no lock): the count can
  # only come out low, so the guard stays quiet rather than nagging.
  { printf '%s %s %s\n' "$sig" "$n" "$now" > "$f"; } 2>/dev/null || exit 0
  max="${SWAP_LOOP_MAX:-3}"; case "$max" in ''|*[!0-9]*|0*|1) max=3;; esac
  if [ "$n" -eq "$max" ]; then
    _auto_log "$(jq -cn --argjson ts "$now" --arg t "$tool" --arg s "$sid" --argjson n "$n" '{ts:($ts|todate), source:"loop-guard", action:"rethink", tool:$t, repeats:$n, session:$s}')"
    printf 'overclaude loop guard: this exact %s call has now failed the same way %s times in a row. Do not run it again unchanged: reread the error, question the assumption behind the call, and try a different approach (or ask the user if you are stuck).\n' "$tool" "$n" >&2
    exit 2
  elif [ "$n" -eq $((max * 2)) ]; then
    _auto_log "$(jq -cn --argjson ts "$now" --arg t "$tool" --arg s "$sid" --argjson n "$n" '{ts:($ts|todate), source:"loop-guard", action:"stop", tool:$t, repeats:$n, session:$s}')"
    printf 'overclaude loop guard: this exact %s call has failed the same way %s times in a row. Stop retrying it and tell the user what is failing and what you have tried.\n' "$tool" "$n" >&2
    exit 2
  fi
  exit 0
}

# --- checkpoint: wrap up before the account runs out (PostToolUse hook) -------------
# The budget note reaches a session only when the user types; a long autonomous run
# never sees it. This hook runs after every tool call and, the first time the active
# account crosses its line (5-hour window 90%, week 95%, any per-model limit like
# Fable 90%), tells that agent once to finish the step it is on and save progress, so
# a switch at the limit (instant swap) or at 95% (auto-swap) loses nothing half-done.
# Cheap when nothing is near a limit: the band is recomputed only when cswap's usage
# cache or the active account changes. On by default; `checkpoint off` to silence.
CKPT_OFF="$STATE_ROOT/checkpoint.off"
CKPT_STATE="$STATE_ROOT/cache/checkpoint.state"

_ckpt_band() { # "<slot>|<window>|<reset epoch>|<pct>|<label>|<reset hh:mm>" of the window past its line, or nothing
  local u="$STATE_ROOT/cache/usage.json" q="$STATE_ROOT/sequence.json" mu mq line
  # Sub-second time + inode: cswap rewrites usage.json (atomic replace) several times
  # within a second during one refresh.
  mu="$(stat -f '%Fm:%i' "$u" 2>/dev/null || echo 0)"; mq="$(stat -f '%Fm:%i' "$q" 2>/dev/null || echo 0)"
  line="$(cat "$CKPT_STATE" 2>/dev/null)"
  case "$line" in "$mu $mq "*) printf '%s' "${line#"$mu $mq "}"; return 0;; esac
  line="$(jq -rn --slurpfile s "$q" --slurpfile c "$u" --argjson now "$(date +%s)" "$JQ_EPOCH"'
    ($s[0] // {}) as $seq | ((($c[0] // {}).accounts // {})) as $acc
    | (($seq.activeAccountNumber // "") | tostring) as $n
    | (($acc[$n].lastGood) // {}) as $g
    | ($seq.accounts[$n] // {}) as $a
    | ((($a.alias // "") | tostring) | if . == "" then (($a.email // "account") | tostring | split("@")[0]) else . end
       | gsub("[\u0000-\u001f\u007f|]"; "")) as $lbl
    | [ {name: "5h", w: $g.five_hour, line: 90}, {name: "week", w: $g.seven_day, line: 95} ]
      + [ $g.scoped[]? | {name: (.name | tostring | gsub("[^A-Za-z0-9._-]"; "")), w: ., line: 90} | select(.name != "") ]
    | map(((.w.resets_at // "") | ep) as $r | ((.w.pct // 0) | tonumber? // 0) as $p
          | select(.w != null and ($r == null or $r > $now) and $p >= .line)
          | {name, pct: ($p | floor), r: ($r // 0)})
    | sort_by(-.pct) | first
    | if . == null or $n == "" then "" else
        "\($n)|\(.name)|\(.r)|\(.pct)|\($lbl)|\(if .r > 0 then (.r | strflocaltime("%a %H:%M")) else "" end)" end' 2>/dev/null)"
  mkdir -p "$STATE_ROOT/cache" 2>/dev/null && printf '%s %s %s\n' "$mu" "$mq" "$line" > "$CKPT_STATE.tmp.$$" &&
    mv -f "$CKPT_STATE.tmp.$$" "$CKPT_STATE"
  printf '%s' "$line"
}

cmd_checkpoint() {
  local input sid aid band key f slot win reset pct lbl when msg
  case "${1:-}" in
    on)  rm -f "$CKPT_OFF"; jq -cn '{checkpoint:true}'; exit 0;;
    off) mkdir -p "$STATE_ROOT" && touch "$CKPT_OFF"; jq -cn '{checkpoint:false}'; exit 0;;
    status|"") jq -cn --argjson on "$([ -f "$CKPT_OFF" ] && echo false || echo true)" '{checkpoint:$on}'; exit 0;;
    --hook) :;;
    *) echo "usage: swap-guard checkpoint on|off|status" >&2; exit 2;;
  esac
  input="$(cat 2>/dev/null)" || input=""
  command -v jq >/dev/null 2>&1 || exit 0
  # Memory (mem nudge): the cached level, read without jq while it is "ok".
  local mlvl="" mmsg=""
  [ -f "$MEM_NUDGE_OFF" ] || mlvl="$(_mem_fresh_level)"
  # A token job (swap-guard token run) bills another account: its usage isn't this one's.
  band=""
  [ -f "$CKPT_OFF" ] || [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || band="$(_ckpt_band)"
  [ -n "$band$mlvl" ] || exit 0
  # Runs on every tool call while a line is crossed: read the two ids without jq when
  # that is unambiguous, else fall back to jq. Only the part before the tool payload is
  # searched (a tool's own input or output can carry "session_id"/"agent_id" keys), and
  # any "agent_id" after it (where the top-level one may sit) means jq decides.
  sid=""; aid=""
  local pre rest re_s='"session_id"[[:space:]]*:[[:space:]]*"([A-Za-z0-9_-]+)"' re_a='"agent_id"[[:space:]]*:[[:space:]]*"([A-Za-z0-9_-]+)"'
  pre="${input%%\"tool_input\"*}"; rest="${input#"$pre"}"
  if [[ $pre =~ $re_s ]] && { sid="${BASH_REMATCH[1]}"; [[ $pre =~ $re_a ]] && aid="${BASH_REMATCH[1]}"
       [[ $rest != *'"agent_id"'* ]] && { [ -n "$aid" ] || [[ $pre != *'"agent_id"'* ]]; }; }; then
    :
  else
    aid=""
    IFS=$'\x1f' read -r sid aid <<EOF
$(printf '%s' "$input" | jq -r '[(.session_id // ""), (.agent_id // "")] | map(tostring | gsub("[^A-Za-z0-9_-]"; "")) | join("\u001f")' 2>/dev/null)
EOF
  fi
  [ -n "$sid" ] || exit 0
  aid="$(printf '%s' "$aid" | cut -c1-64)"
  [ -n "$mlvl" ] && mmsg="$(_mem_note "$sid" "$aid" "$mlvl")"
  msg=""
  IFS='|' read -r slot win reset pct lbl when <<EOF
$band
EOF
  case "$slot$reset$pct" in ''|*[!0-9]*) band="";; esac
  # Once per agent per account and window (a new window or account can nudge again).
  key="$slot|$win|$reset"; f="$CTX_DIR/$sid${aid:+.$aid}.ckpt"
  [ -n "$band" ] && [ "$(cat "$f" 2>/dev/null)" = "$key" ] && band=""
  # Can't record it (unwritable state)? Then stay silent rather than repeat on every call.
  if [ -n "$band" ] && { mkdir -p "$CTX_DIR" && printf '%s\n' "$key" > "$f"; } 2>/dev/null; then
    msg="overclaude: account $lbl is at $win $pct%${when:+ (resets $when)}, close to its limit. "
    if [ -n "$aid" ]; then
      msg="${msg}You are a subagent: finish the task you are on and return your result now, without starting more work."
    else
      msg="${msg}Wrap up before it runs out: finish the step you are on, save progress so the work can resume (write results to files, commit if this is a repo, and for a long task keep a short notes file of where you are and what is next), and do not start a new batch of subagents or a long workflow right now. If the limit is reached, the account switches automatically and you will be told to continue."
    fi
    _auto_log "$(jq -cn --argjson ts "$(date +%s)" --arg s "$sid" --arg a "$aid" --arg w "$win" --argjson p "$pct" \
      '{ts:($ts|todate), source:"checkpoint", action:"nudged", window:$w, pct:$p, session:$s} + (if $a != "" then {agent:$a} else {} end)')"
  fi
  [ -n "$mmsg" ] && msg="${msg:+$msg }$mmsg"
  [ -n "$msg" ] || exit 0
  jq -cn --arg m "$msg" '{hookSpecificOutput:{hookEventName:"PostToolUse", additionalContext:$m}}'
  exit 0
}

# --- mem: this Mac's memory — show it, clean up what is dead, warn every session ----
# Several sessions plus their test runs, builds and MCP servers can push a Mac into
# heavy swapping, and then every session stops responding at once. A LaunchAgent (on
# by default, every 60 s) samples memory pressure, free %, swap and the compressor
# (sysctl, memory_pressure, vm_stat: milliseconds) into cache/mem.json. The statusline
# shows it and the hooks read it. Levels: warn at kernel pressure "warn" or free below
# 20%; critical at pressure "critical" or free below 10% — or, while memory is tight
# either way, swap above 25% (warn) / 50% (critical) of RAM. At warn or critical the tick:
#   - cleans up orphans (auto-clean, on by default) and notifies with what is using
#     the memory and what to close;
#   - every session is told once (PostToolUse, via the checkpoint hook) to hold back:
#     no new subagents, workflows or parallel test runs, and to save progress;
#   - at critical the agent gate (PreToolUse on Agent|Task|Workflow) refuses new agents.
# It never closes an app or a live session. An orphan is cleaned only when its session
# is provably gone: your own process, reparented to launchd, not a launchd job and not
# a Claude process, that looks like a helper a session starts (MCP servers, npx/uvx,
# test runners, esbuild, ffmpeg-static, headless browsers), whose stdin pipe/socket has
# lost its other end (see _mem_procs), older than 10 min, idle (CPU < 1%) and not
# listening on a port. Anything else is only listed and suggested.
MEM_CACHE="$STATE_ROOT/cache/mem.json"
MEM_STATE="$STATE_ROOT/cache/mem.state"
MEM_LOG="$STATE_ROOT/mem.log"
MEM_LABEL="com.overclaude.memwatch"
MEM_OFF="$STATE_ROOT/memwatch.off"
MEM_CLEAN_OFF="$STATE_ROOT/mem-autoclean.off"
MEM_GATE_OFF="$STATE_ROOT/mem-gate.off"
MEM_NUDGE_OFF="$STATE_ROOT/mem-nudge.off"
MEM_INTERVAL=60
MEM_FRESH=180      # hooks act on a sample at most this old (seconds)
MEM_RENOTIFY=900   # while critical, notify again at most this often
MEM_CALM=300       # memory must stay fine this long before "back to normal" (and a new alert)
MEM_RENUDGE=1800   # a session is reminded again at most this often at the same level

_mem_num() { # _mem_num <value> <default> — the value if it is a whole number, else the default
  case "${1:-}" in ''|*[!0-9]*) printf '%s' "$2";; *) printf '%s' "$1";; esac
}

_mem_log() { # _mem_log <json> — append + keep the last 500 lines
  mkdir -p "$STATE_ROOT" 2>/dev/null || return 0
  printf '%s\n' "$1" >> "$MEM_LOG"
  if [ "$(wc -l < "$MEM_LOG" 2>/dev/null || echo 0)" -gt 600 ]; then
    tail -n 500 "$MEM_LOG" > "$MEM_LOG.tmp.$$" && mv "$MEM_LOG.tmp.$$" "$MEM_LOG"
  fi
}

# _mem_sys — one JSON object: the system numbers and the level they add up to.
_mem_sys() {
  local ram lvl sw free comp swu swt wf cf
  ram="$(sysctl -n hw.memsize 2>/dev/null)"
  case "$ram" in ''|*[!0-9]*) return 1;; esac
  lvl="$(_mem_num "$(sysctl -n kern.memorystatus_vm_pressure_level 2>/dev/null)" 0)"
  sw="$(sysctl -n vm.swapusage 2>/dev/null)"
  free="$(memory_pressure -Q 2>/dev/null | sed -n 's/.*free percentage: *\([0-9][0-9]*\)%.*/\1/p' | head -n 1)"
  free="$(_mem_num "$free" -1)"
  comp="$(vm_stat 2>/dev/null | awk '
    /page size of/ { for (i = 1; i <= NF; i++) if ($i == "of") ps = $(i + 1) + 0 }
    /occupied by compressor/ { v = $NF; sub(/\.$/, "", v); c = v + 0 }
    END { if (ps > 0) printf "%d", c * ps / 1048576; else printf "0" }')"
  # "total = 5120.00M  used = 3800.19M  free = 1319.81M  (encrypted)"
  read -r swt swu <<EOF
$(printf '%s' "$sw" | awk '
  function mb(v,  n) { n = v + 0; if (v ~ /G$/) n *= 1024; else if (v ~ /K$/) n /= 1024; return int(n) }
  { for (i = 1; i + 2 <= NF; i++) { if ($i == "total" && $(i + 1) == "=") t = mb($(i + 2)); if ($i == "used" && $(i + 1) == "=") u = mb($(i + 2)) } }
  END { printf "%d %d\n", t, u }')
EOF
  wf="$(_mem_num "${SWAP_MEM_WARN_FREE:-}" 20)"; cf="$(_mem_num "${SWAP_MEM_CRIT_FREE:-}" 10)"
  jq -cn --argjson ram "$(( ram / 1048576 ))" --argjson lvl "$lvl" --argjson free "$free" \
    --argjson su "$(_mem_num "$swu" 0)" --argjson st "$(_mem_num "$swt" 0)" --argjson comp "$(_mem_num "$comp" 0)" \
    --argjson wf "$wf" --argjson cf "$cf" '
    def gb: (. / 1024 * 10 | round) / 10 | tostring + " GB";
    # macOS keeps swap allocated for hours after pressure ends, so swap alone never
    # raises the level: it only counts while memory is actually tight.
    ($lvl >= 2 or ($free >= 0 and $free < $wf)) as $tight
    | [ (if $lvl >= 4 then "kernel memory pressure is critical" else empty end),
      (if $free >= 0 and $free < $cf then "only \($free)% of memory is free" else empty end),
      (if $tight and $su > $ram * 0.5 then "\($su | gb) of swap in use" else empty end) ] as $crit
    | [ (if $lvl == 2 then "kernel memory pressure is raised" else empty end),
        (if $free >= 0 and $free < $wf then "only \($free)% of memory is free" else empty end),
        (if $tight and $su > $ram * 0.25 then "\($su | gb) of swap in use" else empty end) ] as $warn
    | {level: (if ($crit | length) > 0 then "critical" elif ($warn | length) > 0 then "warn" else "ok" end),
       reasons: (if ($crit | length) > 0 then $crit else $warn end),
       ramMB: $ram, freePct: (if $free < 0 then null else $free end),
       usedPct: (if $free < 0 then null else 100 - $free end),
       pressure: (if $lvl >= 4 then "critical" elif $lvl == 2 then "warn" elif $lvl == 1 then "normal" else "unknown" end),
       swapUsedMB: $su, swapTotalMB: $st, compressedMB: $comp}'
}

# _mem_procs [--footprint] — JSON {basis, apps, sessions, orphans}. Memory is RSS from
# ps (cheap), or with --footprint the footprint top reports (includes compressed
# pages: an idle orphan can show 15 MB RSS but hold 700 MB; top costs ~1.5 s).
_mem_procs() {
  local fp=0 tmp me self orphan_min only now
  [ "${1:-}" = "--footprint" ] && fp=1
  tmp="$(mktemp -d "${TMPDIR:-/tmp}/swap-guard-mem.XXXXXX")" || return 1
  me="$(id -u)"; self="$$"
  orphan_min="$(_mem_num "${SWAP_MEM_ORPHAN_MIN:-}" 600)"
  # Tests only: consider nothing but processes matching this, so a test run can never
  # touch the developer's real processes.
  only="${SWAP_MEM_TEST_ONLY:-}"
  ps -axwwo pid=,ppid=,uid=,rss=,%cpu=,etime=,command= > "$tmp/ps" 2>/dev/null
  now="$(date +%s)"   # after ps: start times are now - elapsed, and ps can take a while
  : > "$tmp/fp"
  [ "$fp" -eq 1 ] && top -l 1 -stats pid,mem -n 5000 2>/dev/null | awk '
    function mb(v,  n) { sub(/[+-]$/, "", v); n = v + 0
      if (v ~ /G$/) n *= 1024; else if (v ~ /K$/) n /= 1024; else if (v ~ /B$/) n /= 1048576; return n }
    go && $1 ~ /^[0-9]+$/ { printf "%s\t%.1f\n", $1, mb($2) }
    $1 == "PID" { go = 1 }' > "$tmp/fp"
  : > "$tmp/sess"
  local f
  for f in "$SESSIONS_DIR"/*.json; do
    [ -f "$f" ] || continue
    jq -r '[(.pid // "" | tostring), (.sessionId // ""), (.cwd // ""), (.status // "")]
      | map(gsub("[\t\n\r]"; " ")) | @tsv' "$f" 2>/dev/null >> "$tmp/sess"
  done
  launchctl list 2>/dev/null | awk 'NR > 1 && $1 ~ /^[0-9]+$/ { print $1 }' > "$tmp/ld"
  awk -F'\t' -v me="$me" -v self="$self" -v omin="$orphan_min" -v only="$only" -v now="$now" '
    function secs(e,  d, a, n) {
      d = 0; if (index(e, "-")) { d = substr(e, 1, index(e, "-") - 1); e = substr(e, index(e, "-") + 1) }
      n = split(e, a, ":")
      if (n == 3) return d * 86400 + a[1] * 3600 + a[2] * 60 + a[3]
      if (n == 2) return d * 86400 + a[1] * 60 + a[2]
      return a[1] + 0 }
    function app(c, p,   n, t, q) {
      if (p in sess || c ~ /\/claude\/versions\//) return "Claude Code"
      if (match(c, /\/[^\/]*\.app\//)) { bundle = 1; return substr(c, RSTART + 1, RLENGTH - 6) }
      split(c, t, " "); n = split(t[1], q, "/"); q[n] = (n > 0 ? q[n] : t[1])
      if (q[n] == "claude") return "Claude Code"
      return q[n] }
    function helper(c) {
      c = tolower(c)
      # Apps and the OS are never helpers, except an interpreter that ships inside one
      # (the python3 inside Xcode, an app-bundled node) running a script, e.g. an MCP server.
      if ((c ~ /^\/(applications|system)\// || c ~ /^\/library\/apple\//) && c !~ /\/(python[0-9.]*|node|ruby|bun|deno) /) return 0
      if (c ~ /^\/usr\/(libexec|sbin)\//) return 0
      return (c ~ /(^|\/)(npm|pnpm|yarn|bun) (exec|dlx|x) / || c ~ /(^|\/)(npx|uvx|bunx)( |$)/ || c ~ /\/_npx\// ||
              c ~ /(^|[ \/@_.-])mcp([ \/@_.:-]|$)/ || c ~ /mcp-server|server-mcp/ || c ~ /node_modules\/\.bin\// ||
              c ~ /(^|[ \/(])vitest/ || c ~ /esbuild --service/ || c ~ /(^|\/)tsx( |$)/ || c ~ /(^|\/)jest( |$)/ ||
              c ~ /ffmpeg-static/ || c ~ /chrome-headless-shell|ms-playwright/) }
    FILENAME == ARGV[1] { if ($2 != "") fpm[$1] = $2 + 0; next }
    FILENAME == ARGV[2] { if ($1 ~ /^[0-9]+$/) { sess[$1] = 1; sid[$1] = $2; scwd[$1] = $3; sst[$1] = $4 }; next }
    FILENAME == ARGV[3] { ld[$1] = 1; next }
    {
      # ps: "  pid ppid uid rss %cpu etime command..." (space-separated; command keeps its spaces)
      line = $0; sub(/^ +/, "", line)
      n = split(line, w, / +/)
      if (n < 7 || w[1] !~ /^[0-9]+$/) next
      p = w[1]; pp[p] = w[2]; uid[p] = w[3]; cpu[p] = w[5] + 0; age[p] = secs(w[6])
      c = line; for (i = 1; i <= 6; i++) sub(/^[^ ]+ +/, "", c)
      cmd[p] = c
      m[p] = (p in fpm) ? fpm[p] : w[4] / 1024
      pids[++np] = p
    }
    END {
      for (i = 1; i <= np; i++) {
        p = pids[i]
        bundle = 0; a = app(cmd[p], p); am[a] += m[p]; ac[a]++; if (bundle) ab[a] = 1
        # A Claude Code process is a session (maybe a background one: claude --bg / -p),
        # never an orphan, even when its argv mentions mcp or npx.
        if (pp[p] == 1 && uid[p] == me && p != self && !(p in ld) && !(p in sess) &&
            cmd[p] !~ /(^|\/)claude( |$)|\/claude\/versions\// &&
            (only != "" ? (cmd[p] ~ only) : helper(cmd[p]))) { root[p] = 1; tp[p] = p ":" (now - age[p]) }
      }
      # An orphan with a session or any Claude process below it (an npx wrapper around
      # claude, registered or not) is no orphan.
      for (i = 1; i <= np; i++) {
        q = pids[i]
        if (!(q in sess) && cmd[q] !~ /(^|\/)claude( |$)|\/claude\/versions\//) continue
        r = pp[q]; for (k = 0; k < 64 && r != "" && (r + 0) > 1; k++) { if (r in root) delete root[r]; r = pp[r] }
      }
      # Attribute every process to the session or orphan above it (walk up the parents).
      for (i = 1; i <= np; i++) {
        p = pids[i]; q = p
        for (k = 0; k < 64 && q != "" && (q + 0) > 1; k++) {
          if (q in sess) { tree[q] += m[p]; break }
          if (q in root) { tree[q] += m[p]; tcpu[q] += cpu[p]; if (p != q) tp[q] = tp[q] " " p ":" (now - age[p]); break }
          q = pp[q]
        }
      }
      for (a in am) printf "A\t%s\t%.0f\t%d\t%d\n", a, am[a], ac[a], (a in ab ? 1 : 0)
      for (q in sess) if ((q in m) && (cmd[q] ~ /claude/)) printf "S\t%s\t%.0f\t%.0f\t%s\t%s\t%s\n", q, m[q], tree[q], sid[q], scwd[q], sst[q]
      for (q in root) printf "O\t%s\t%.0f\t%d\t%.1f\t%s\t%d\t%s\n", q, tree[q], age[q], tcpu[q], tp[q], (age[q] >= omin ? 1 : 0), substr(cmd[q], 1, 300)
    }' "$tmp/fp" "$tmp/sess" "$tmp/ld" "$tmp/ps" > "$tmp/out" 2>/dev/null
  # Orphan trees listening on a TCP port are dev servers someone may want: never auto-cleaned.
  local opids roots
  opids="$(awk -F'\t' '$1 == "O" { gsub(/:[0-9]+/, "", $6); gsub(/ /, ",", $6); printf "%s%s", (n++ ? "," : ""), $6 }' "$tmp/out")"
  roots="$(awk -F'\t' '$1 == "O" { printf "%s%s", (n++ ? "," : ""), $2 }' "$tmp/out")"
  : > "$tmp/listen"; : > "$tmp/stdin"
  [ -n "$opids" ] && lsof -nP -a -iTCP -sTCP:LISTEN -p "$opids" -Fpn 2>/dev/null |
    awk '/^p/ { p = substr($0, 2) } /^n/ { n = substr($0, 2); sub(/.*:/, "", n); print p "\t" n }' > "$tmp/listen"
  # The proof a helper's session is gone: its stdin AND stdout were a pipe or socket to
  # whatever started it (how a session talks to a stdio MCP server), and nothing holds
  # the other end any more (lsof: a pipe with an empty name, a socket "->(none)"; a live
  # one shows "->0x…"). A session's own `cmd &` (even `producer | cmd &`) writes stdout to
  # a file or /dev/null, so it never qualifies. No lsof → nothing qualifies.
  [ -n "$roots" ] && lsof -nP -a -p "$roots" -d 0,1 -Ffptn 2>/dev/null |
    awk 'function done() { if (p != "" && gone[p, "0"] && gone[p, "1"]) print p }
         /^p/ { done(); p = substr($0, 2); f = ""; t = "" }
         /^f/ { f = substr($0, 2); t = "" }
         /^t/ { t = substr($0, 2) }
         /^n/ { n = substr($0, 2); if ((t == "PIPE" && n == "") || (t == "unix" && n == "->(none)")) gone[p, f] = 1 }
         END { done() }' > "$tmp/stdin"
  # Idle time of each session: its transcript's last write.
  awk -F'\t' '$1 == "S" { print $2 "\t" $5 "\t" $6 }' "$tmp/out" | while IFS="$(printf '\t')" read -r p s c; do
    t="$HOME/.claude/projects/$(printf '%s' "$c" | sed 's/[^A-Za-z0-9]/-/g')/$s.jsonl"
    mt="$(stat -f %m "$t" 2>/dev/null)"; mt="$(_mem_num "$mt" "")"
    printf '%s\t%s\n' "$p" "${mt:+$(( now - mt ))}"
  done > "$tmp/idle"
  jq -Rn --argjson fp "$fp" --rawfile listen "$tmp/listen" --rawfile idle "$tmp/idle" --rawfile dead "$tmp/stdin" '
    ($listen | split("\n") | map(select(. != "") | split("\t")) | group_by(.[0])
      | map({key: .[0][0], value: (map(.[1]) | unique)}) | from_entries) as $ports
    | ($idle | split("\n") | map(select(. != "") | split("\t")) | map({key: .[0], value: (.[1] | tonumber? // null)}) | from_entries) as $idl
    | ($dead | split("\n") | map(select(. != ""))) as $dead
    | [inputs | split("\t")] as $rows
    | {basis: (if $fp == 1 then "footprint" else "rss" end),
       apps: ([$rows[] | select(.[0] == "A") | {name: .[1], mb: (.[2] | tonumber), procs: (.[3] | tonumber), app: (.[4] == "1")}]
              | sort_by(-.mb) | .[:10]),
       sessions: ([$rows[] | select(.[0] == "S") | {pid: (.[1] | tonumber), mb: (.[2] | tonumber), treeMB: (.[3] | tonumber),
                   sessionId: .[4], cwd: .[5], status: (if .[6] == "" then null else .[6] end), idleSec: $idl[.[1]]}]
                  | sort_by(-.treeMB)),
       orphans: ([$rows[] | select(.[0] == "O")
                  | (.[5] | split(" ") | map(split(":") | map(tonumber))) as $procs
                  | ([$procs[] | (.[0] | tostring) as $k | ($ports[$k] // empty)[]] | unique) as $pt
                  | (.[1] as $p | $dead | index([$p]) != null) as $gone
                  | {pid: (.[1] | tonumber), mb: (.[2] | tonumber), ageSec: (.[3] | tonumber), cpu: (.[4] | tonumber),
                     pids: [$procs[] | .[0]], starts: [$procs[] | .[1]], ports: $pt, cmd: .[7], sessionGone: $gone,
                     auto: ($gone and .[6] == "1" and (.[4] | tonumber) < 1 and ($pt | length) == 0)}]
                 | sort_by(-.mb))}' < "$tmp/out"
  rm -rf "$tmp"
}

# _mem_snapshot [--footprint] — sample everything, write the cache, print it.
_mem_snapshot() {
  local sys procs out
  sys="$(_mem_sys)" || { printf '%s\n' '{"error":"cannot read memory (sysctl)"}'; return 1; }
  procs="$(_mem_procs "$@")"; printf '%s' "$procs" | jq -e '.apps' >/dev/null 2>&1 || procs='{"basis":"none","apps":[],"sessions":[],"orphans":[]}'
  out="$(jq -cn --argjson s "$sys" --argjson p "$procs" --argjson at "$(date +%s)" '{at: $at, level: $s.level} + $s + $p')" || return 1
  mkdir -p "$STATE_ROOT/cache" 2>/dev/null && printf '%s\n' "$out" > "$MEM_CACHE.tmp.$$" && mv -f "$MEM_CACHE.tmp.$$" "$MEM_CACHE"
  printf '%s\n' "$out"
}

# What to do about it, most useful first (shared by the text report and notifications).
MEM_JQ_TIPS='
  def gb: if . >= 1024 then "\((. / 1024 * 10 | round) / 10) GB" else "\(. | round) MB" end;
  def ago: if . == null then "?" elif . >= 86400 then "\(. / 86400 | floor)d \(. % 86400 / 3600 | floor)h"
           elif . >= 3600 then "\(. / 3600 | floor)h \(. % 3600 / 60 | floor)m" else "\(. / 60 | floor)m" end;
  def pad($n): . + (if $n - length > 0 then " " * ($n - length) else "" end);
  def dir: (.cwd // "" | split("/") | last) as $d | if $d == "" then "?" else $d end;
  def tips:
    [ (.orphans[] | select((.auto | not) and .ageSec >= 600 and .cpu < 1 and .mb >= 50)
        | "Orphaned \(.cmd | split(" ") | .[0:3] | join(" ") | .[0:60]) (pid \(.pid), \(.mb | gb)\(if (.ports | length) > 0 then ", listening on :\(.ports | join(",:"))" else "" end)): stop it if nothing uses it: swap-guard mem clean --pid \(.pid)"),
      (.sessions[] | select((.treeMB - .mb) >= 1024)
        | "The session in \(dir) is running \((.treeMB - .mb) | gb) of child processes (tests, builds): let them finish, and run fewer at once"),
      (.sessions[] | select((.idleSec // 0) >= 7200 and .status != "busy")
        | "The session in \(dir) has been idle \(.idleSec | ago) (\(.mb | gb)): /exit it if you are done"),
      (.apps[] | select(.app and .name != "Claude Code" and .mb >= 2048)
        | "\(.name) uses \(.mb | gb)\(if .name | test("Chrome|Safari|Arc|Firefox|Edge|Brave") then ": close tabs you do not need" else ": quit it if you are not using it" end)") ];'

_mem_text() { # _mem_text <snapshot json>
  printf '%s' "$1" | jq -r --argjson clean "$([ -f "$MEM_CLEAN_OFF" ] && echo false || echo true)" \
    --argjson gate "$([ -f "$MEM_GATE_OFF" ] && echo false || echo true)" \
    --argjson nudge "$([ -f "$MEM_NUDGE_OFF" ] && echo false || echo true)" \
    --argjson watch "$([ -f "$HOME/Library/LaunchAgents/$MEM_LABEL.plist" ] && echo true || echo false)" "$MEM_JQ_TIPS"'
    . as $s
    | "Memory: \(.ramMB | gb) · \(if .usedPct == null then "?" else "\(.usedPct)%" end) in use · swap \(.swapUsedMB | gb) · compressed \(.compressedMB | gb) · pressure \(.pressure) → \(.level | ascii_upcase)"
      + (if (.reasons | length) > 0 then "\n  (" + (.reasons | join("; ")) + ")" else "" end),
      "",
      "Biggest apps\(if .basis == "rss" then " (resident memory; run with --footprint for compressed too)" else "" end):",
      (.apps[:8][] | "  \(.name | .[0:28] | pad(28)) \(.mb | gb)  (\(.procs) proc\(if .procs == 1 then "" else "s" end))"),
      "",
      "Claude sessions: \(.sessions | length)\(if (.sessions | length) > 0 then ", \([.sessions[].treeMB] | add | gb) with their child processes" else "" end)",
      (.sessions[] | "  pid \(.pid | tostring | pad(6)) \(dir | .[0:24] | pad(24)) \(.mb | gb)"
         + (if (.treeMB - .mb) >= 50 then " (+\((.treeMB - .mb) | gb) children)" else "" end)
         + "  \(.status // "?")" + (if .idleSec != null then ", last active \(.idleSec | ago) ago" else "" end)),
      (if (.orphans | length) > 0 then "", "Orphaned helper processes (their session is gone):",
        (.orphans[] | "  pid \(.pid | tostring | pad(6)) \(.mb | gb)  \(.ageSec | ago) old  \(.cmd | .[0:70])"
           + (if .auto then "  → cleaned automatically when memory gets tight (now: swap-guard mem clean)"
              elif (.ports | length) > 0 then "  → listening on :\(.ports | join(",:")), left alone"
              elif .cpu >= 1 then "  → still working (\(.cpu)% CPU), left alone"
              elif (.sessionGone | not) then "  → may still belong to a live session, left alone"
              else "  → under 10 min old, left alone" end))
       else empty end),
      (tips as $t | if ($t | length) > 0 then "", "Suggestions:", ($t[] | "  - " + .) else empty end),
      "",
      "Watcher: \(if $watch then "on (every minute)" else "off (swap-guard mem watch on)" end) · auto-clean: \(if $clean then "on" else "off" end) · session notes: \(if $nudge then "on" else "off" end) · agent gate at critical: \(if $gate then "on" else "off" end)"'
}

# _mem_started <pid> — when the process started (epoch, from its elapsed time), or nothing.
_mem_started() {
  local e
  e="$(ps -o etime= -p "$1" 2>/dev/null | tr -d ' ')"
  [ -n "$e" ] || return 0
  printf '%s' "$e" | awk -v now="$(date +%s)" '{ d = 0; e = $0
    if (index(e, "-")) { d = substr(e, 1, index(e, "-") - 1); e = substr(e, index(e, "-") + 1) }
    n = split(e, a, ":"); s = (n == 3 ? a[1] * 3600 + a[2] * 60 + a[3] : (n == 2 ? a[1] * 60 + a[2] : a[1]))
    print now - (d * 86400 + s) }'
}

# _mem_kill "<pid>:<start> ..." — the orphan first, then its children. Each process is
# signalled only if it is still the one the snapshot saw (same pid AND start time within
# 2 s), and the orphan only while its parent is still launchd — a reused pid is never
# hit. TERM, then KILL whatever is left after 3 s. Returns 1 if the orphan itself is
# no longer the same process.
_mem_kill() {
  local spec p st cur ok="" i left first=1
  for spec in $1; do
    p="${spec%%:*}"; st="${spec#*:}"
    case "$p$st" in ''|*[!0-9]*) [ "$first" -eq 1 ] && return 1; continue;; esac
    cur="$(_mem_started "$p")"
    if [ -z "$cur" ] || [ "$(( cur > st ? cur - st : st - cur ))" -gt 2 ]; then [ "$first" -eq 1 ] && return 1; continue; fi
    if [ "$first" -eq 1 ]; then [ "$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ')" = 1 ] || return 1; fi
    ok="$ok $p"; first=0
  done
  [ -n "$ok" ] || return 1
  for p in $ok; do kill -TERM "$p" 2>/dev/null; done
  for i in 1 2 3 4 5 6; do
    left=""
    for p in $ok; do kill -0 "$p" 2>/dev/null && left="$left $p"; done
    [ -n "$left" ] || return 0
    sleep 0.5
  done
  for p in $left; do kill -KILL "$p" 2>/dev/null; done
  return 0
}

# _mem_clean <snapshot json> [--dry-run] [--pid N]... — stop the auto-cleanable orphans,
# or exactly the ones --pid names (any listed orphan). JSON {cleaned, notStopped, freedMB, dryRun}.
_mem_clean() {
  local snap="$1" dry=0 only="" sel pid spec stopped="" failed="" v
  shift
  while [ $# -gt 0 ]; do
    case "$1" in
      --dry-run) dry=1;;
      --pid) v="$(_mem_num "${2:-}" x)"; [ "$v" = x ] && { printf '%s\n' '{"error":"--pid needs a process id"}'; return 2; }
             only="$only $v"; shift;;
      --pid=*) v="$(_mem_num "${1#--pid=}" x)"; [ "$v" = x ] && { printf '%s\n' '{"error":"--pid needs a process id"}'; return 2; }
               only="$only $v";;
      *) jq -cn --arg a "$1" '{error: ("unknown argument: " + $a + " (use: swap-guard mem clean [--dry-run] [--pid N]...)")}'; return 2;;
    esac
    shift
  done
  sel="$(printf '%s' "$snap" | jq -c --arg only "$only" '
    ($only | split(" ") | map(select(. != "") | tonumber)) as $want
    | [.orphans[] | select(if ($want | length) == 0 then .auto else (.pid as $p | $want | index([$p]) != null) end)]')"
  if [ -n "$only" ]; then
    for pid in $only; do
      printf '%s' "$sel" | jq -e --argjson p "$pid" 'any(.[]; .pid == $p)' >/dev/null 2>&1 ||
        { jq -cn --arg p "$pid" '{error: ("pid " + $p + " is not an orphaned helper process (see: swap-guard mem --text)")}'; return 1; }
    done
  fi
  while IFS="$(printf '\t')" read -r pid spec; do
    case "$pid" in ''|*[!0-9]*) continue;; esac
    if [ "$dry" -eq 1 ] || _mem_kill "$spec"; then stopped="$stopped $pid"; else failed="$failed $pid"; fi
  done <<EOF
$(printf '%s' "$sel" | jq -r '.[] | "\(.pid)\t\([.pids, .starts] | transpose | map("\(.[0]):\(.[1])") | join(" "))"')
EOF
  local out
  out="$(printf '%s' "$sel" | jq -c --arg s "$stopped" --arg f "$failed" --argjson d "$dry" '
    ($s | split(" ") | map(select(. != "") | tonumber)) as $ok | ($f | split(" ") | map(select(. != "") | tonumber)) as $no
    | [.[] | select(.pid as $p | $ok | index([$p]) != null) | {pid, mb, cmd: (.cmd | .[0:120])}] as $c
    | {cleaned: $c, notStopped: [.[] | select(.pid as $p | $no | index([$p]) != null) | {pid, reason: "no longer the same process"}],
       freedMB: ([$c[].mb] | add // 0), dryRun: ($d == 1)}')"
  if [ "$dry" -eq 0 ] && [ -n "$stopped" ]; then
    _mem_log "$(printf '%s' "$out" | jq -c --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '{ts: $ts, action: "cleaned"} + .')"
  fi
  printf '%s\n' "$out"
}

_mem_notify() { # _mem_notify <message>
  command -v osascript >/dev/null 2>&1 || return 0
  osascript -e "display notification \"$(printf '%s' "$1" | tr -d '"\\' | cut -c1-400)\" with title \"overclaude memory\"" >/dev/null 2>&1 || true
}

_mem_rank() { case "${1:-}" in critical) echo 2;; warn) echo 1;; *) echo 0;; esac; }

_mem_tick() {
  local notify=1 probe=0 sys level fp=0 snap last_fp cleaned='{"cleaned":[],"freedMB":0}' prev_rank rank now msg notified=false
  while [ $# -gt 0 ]; do
    case "$1" in --no-notify) notify=0;; --probe) probe=1;; esac
    shift
  done
  now="$(date +%s)"
  sys="$(_mem_sys)" || {
    msg="$(jq -cn --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '{ts: $ts, action: "error", detail: "cannot read memory (sysctl)"}')"
    [ "$probe" -eq 1 ] || _mem_log "$msg"; printf '%s\n' "$msg"; return 1; }
  if [ "$probe" -eq 1 ]; then jq -cn '{action: "probe-ok"}'; return 0; fi
  level="$(printf '%s' "$sys" | jq -r '.level')"
  # The full footprint (top, ~1.5 s) only when memory is tight, or every 10 min for the report.
  last_fp="$(jq -r 'if .basis == "footprint" then .at else 0 end' "$MEM_CACHE" 2>/dev/null)"
  [ "$level" != ok ] || [ $(( now - $(_mem_num "$last_fp" 0) )) -ge 600 ] && fp=1
  if [ "$fp" -eq 1 ]; then snap="$(_mem_snapshot --footprint)"; else snap="$(_mem_snapshot)"; fi
  printf '%s' "$snap" | jq -e '.level' >/dev/null 2>&1 || return 1
  if [ "$level" != ok ] && [ ! -f "$MEM_CLEAN_OFF" ]; then
    cleaned="$(_mem_clean "$snap")"
  fi
  rank="$(_mem_rank "$level")"
  # State: the rank last notified (kept until memory has been fine for MEM_CALM, so a
  # level flapping around a line notifies once), when, and since when it has been ok.
  local nrank nat oks
  { read -r nrank nat oks < "$MEM_STATE"; } 2>/dev/null
  nrank="$(_mem_num "${nrank:-}" 0)"; nat="$(_mem_num "${nat:-}" 0)"; oks="$(_mem_num "${oks:-}" 0)"
  prev_rank="$nrank"
  if [ "$rank" -gt 0 ]; then oks=0; elif [ "$oks" -eq 0 ]; then oks="$now"; fi
  if [ "$rank" -gt "$nrank" ] || { [ "$rank" -eq 2 ] && [ $(( now - nat )) -ge "$MEM_RENOTIFY" ]; }; then
    if [ "$notify" -eq 1 ]; then
      msg="$(printf '%s' "$snap" | jq -r --argjson c "$cleaned" "$MEM_JQ_TIPS"'
        "Memory \(.level): \(.reasons | join(", "))."
        + (if ($c.cleaned | length) > 0 then " Freed \($c.freedMB | gb) by stopping \($c.cleaned | length) orphaned process\(if ($c.cleaned | length) == 1 then "" else "es" end)." else "" end)
        + " Biggest: " + ([.apps[:2][] | "\(.name) \(.mb | gb)"] | join(", ")) + "."
        + (tips as $t | if ($t | length) > 0 then " " + $t[0] + "." else "" end)
        + " More: swap-guard mem --text"')"
      _mem_notify "$msg"; notified=true
    fi
    nrank="$rank"; nat="$now"
  elif [ "$rank" -eq 0 ] && [ "$nrank" -gt 0 ] && [ $(( now - oks )) -ge "$MEM_CALM" ]; then
    [ "$notify" -eq 1 ] && { _mem_notify "Memory is back to normal."; notified=true; }
    nrank=0; nat="$now"
  elif [ "$notify" -eq 1 ] && [ "$(printf '%s' "$cleaned" | jq '.cleaned | length')" -gt 0 ]; then
    _mem_notify "$(printf '%s' "$cleaned" | jq -r '"Freed \(.freedMB | round) MB by stopping \(.cleaned | length) orphaned process\(if (.cleaned | length) == 1 then "" else "es" end) (memory is tight)."')"
    notified=true
  fi
  mkdir -p "$STATE_ROOT/cache" 2>/dev/null && printf '%s %s %s\n' "$nrank" "$nat" "$oks" > "$MEM_STATE"
  msg="$(printf '%s' "$snap" | jq -c --argjson c "$cleaned" --argjson n "$notified" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
    '{ts: $ts, action: "tick", level, freePct, swapUsedMB, reasons, cleaned: ($c.cleaned | map(.pid)), freedMB: $c.freedMB, notified: $n}')"
  # Log what matters: a tight tick, a level change, a cleanup.
  if [ "$level" != ok ] || [ "$nrank" -ne "$prev_rank" ]; then _mem_log "$msg"; fi
  printf '%s\n' "$msg"
  return 0
}

_mem_watch_on() {
  local interval="$MEM_INTERVAL" path_env probe
  while [ $# -gt 0 ]; do
    case "$1" in --interval) interval="${2:-}"; shift;; esac
    shift
  done
  case "$interval" in ''|*[!0-9]*) echo "mem watch on: --interval must be whole seconds" >&2; return 1;; esac
  [ "$interval" -ge 30 ] || interval=30
  path_env="$(_launchd_path_env)"
  probe="$(PATH="$path_env" _mem_tick --probe --no-notify 2>/dev/null | tail -n 1)"
  if [ "$(printf '%s' "$probe" | jq -r '.action // "error"' 2>/dev/null)" != "probe-ok" ]; then
    echo "mem watch on: a test tick failed, not installing: $probe" >&2; return 1
  fi
  rm -f "$MEM_OFF"
  _launchd_install "$MEM_LABEL" "$interval" "$path_env" "$STATE_ROOT/memwatch-launchd.err" mem tick
}

_mem_watch_off() {
  local plist uid removed=false
  plist="$HOME/Library/LaunchAgents/$MEM_LABEL.plist"; uid="$(id -u)"
  if [ -z "${SWAP_GUARD_NO_LAUNCHCTL:-}" ]; then
    launchctl bootout "gui/$uid/$MEM_LABEL" >/dev/null 2>&1 && removed=true
  fi
  [ -f "$plist" ] && rm -f "$plist" && removed=true
  mkdir -p "$STATE_ROOT" && touch "$MEM_OFF"
  jq -cn --argjson r "$removed" '{memWatch: false, removed: $r}'
}

_mem_watch_status() {
  local plist uid loaded=false
  plist="$HOME/Library/LaunchAgents/$MEM_LABEL.plist"; uid="$(id -u)"
  [ -z "${SWAP_GUARD_NO_LAUNCHCTL:-}" ] && launchctl print "gui/$uid/$MEM_LABEL" >/dev/null 2>&1 && loaded=true
  jq -cn --argjson loaded "$loaded" --argjson exists "$([ -f "$plist" ] && echo true || echo false)" \
    --argjson off "$([ -f "$MEM_OFF" ] && echo true || echo false)" \
    --argjson clean "$([ -f "$MEM_CLEAN_OFF" ] && echo false || echo true)" \
    --argjson gate "$([ -f "$MEM_GATE_OFF" ] && echo false || echo true)" \
    --argjson nudge "$([ -f "$MEM_NUDGE_OFF" ] && echo false || echo true)" \
    --arg plist "$plist" --arg log "$MEM_LOG" \
    '{loaded: $loaded, plistExists: $exists, turnedOff: $off, autoClean: $clean, agentGate: $gate, sessionNotes: $nudge, plist: $plist, log: $log}'
}

# _mem_fresh_level — the cached level if the sample is fresh, else nothing. Cheap on
# the common path: the level sits near the start of the file, so "ok" needs no jq.
_mem_fresh_level() {
  local head at lvl
  [ -f "$MEM_CACHE" ] || return 0
  head="$(head -c 200 "$MEM_CACHE" 2>/dev/null)"
  case "$head" in *'"level":"ok"'*) return 0;; esac
  at="$(printf '%s' "$head" | sed -n 's/^{"at":\([0-9][0-9]*\),.*/\1/p')"
  at="$(_mem_num "$at" 0)"
  [ $(( $(date +%s) - at )) -le "$MEM_FRESH" ] || return 0
  lvl="$(printf '%s' "$head" | sed -n 's/.*"level":"\([a-z]*\)".*/\1/p')"
  case "$lvl" in warn|critical) printf '%s' "$lvl";; esac
}

# _mem_note <sid> <aid> <level> — this agent's memory note, or nothing: once per level,
# again after MEM_RENUDGE. Called from the checkpoint PostToolUse hook (one process
# per tool call, not two).
_mem_note() {
  local sid="$1" aid="$2" lvl="$3" rank f prank pat now line
  case "$lvl" in warn|critical) :;; *) return 0;; esac
  rank="$(_mem_rank "$lvl")"; now="$(date +%s)"
  f="$CTX_DIR/$sid${aid:+.$aid}.mem"
  { read -r prank pat < "$f"; } 2>/dev/null
  prank="$(_mem_num "${prank:-}" 0)"; pat="$(_mem_num "${pat:-}" 0)"
  [ "$rank" -gt "$prank" ] || [ $(( now - pat )) -ge "$MEM_RENUDGE" ] || return 0
  { mkdir -p "$CTX_DIR" && printf '%s %s\n' "$rank" "$now" > "$f"; } 2>/dev/null || return 0
  line="$(jq -r '"\(.reasons | join(", ")); \(.sessions | length) Claude session\(if (.sessions | length) == 1 then "" else "s" end) open"' "$MEM_CACHE" 2>/dev/null)"
  _mem_log "$(jq -cn --argjson ts "$now" --arg s "$sid" --arg a "$aid" --arg l "$lvl" \
    '{ts: ($ts | todate), action: "nudged", level: $l, session: $s} + (if $a != "" then {agent: $a} else {} end)')"
  if [ -n "$aid" ]; then
    printf 'overclaude: this Mac is %s on memory (%s). You are a subagent: finish your task with as few new processes as possible, stop any background process you started, and return your result.' \
      "$([ "$lvl" = critical ] && echo critically low || echo getting low)" "$line"
  elif [ "$lvl" = critical ]; then
    printf 'overclaude: this Mac is critically low on memory (%s); if it keeps climbing, every session on it can freeze. Until it recovers: do not start subagents, workflows, parallel test runs or builds; run tests one file at a time or with few workers (e.g. --maxWorkers=2); stop background processes you started and no longer need; finish the step you are on and save progress (files, a commit). Tell the user, and point them at `swap-guard mem --text` for what is using the memory.' "$line"
  else
    printf 'overclaude: memory on this Mac is getting tight (%s). Keep fan-out small (at most 2 subagents at a time), avoid running test suites or builds in parallel, and stop background processes you no longer need.' "$line"
  fi
}

# mem --gate: PreToolUse on Agent|Task|Workflow. At critical, refuse new agents.
_mem_gate() {
  local input lvl tool
  if [ -t 0 ]; then jq -cn --argjson on "$([ -f "$MEM_GATE_OFF" ] && echo false || echo true)" '{agentGate: $on}'; return 0; fi
  exec 2>/dev/null
  input="$(cat)" || return 0
  [ -f "$MEM_GATE_OFF" ] && return 0
  [ "${OVERCLAUDE_MEM_GATE:-}" = off ] && return 0
  command -v jq >/dev/null 2>&1 || return 0
  lvl="$(_mem_fresh_level)"; [ "$lvl" = critical ] || return 0
  tool="$(printf '%s' "$input" | jq -r '.tool_name // empty' | tr -cd 'A-Za-z0-9_')"; tool="${tool:-subagent}"
  _mem_log "$(jq -cn --argjson ts "$(date +%s)" --arg t "$tool" '{ts: ($ts | todate), action: "gated", tool: $t}')"
  jq -cn --arg r "overclaude memory guard: this Mac is critically low on memory ($(jq -r '.reasons | join(", ")' "$MEM_CACHE" 2>/dev/null)), so starting a $tool now could freeze every session on it. Do this step yourself in this session, one thing at a time, or wait a few minutes and try again; if it cannot wait, tell the user (they can see what uses the memory with: swap-guard mem --text, and turn this gate off with: swap-guard mem gate off)." \
    '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
}

cmd_mem() {
  local sub="${1:-}" snap text=0 fp=0 a
  case "$sub" in
    tick) shift; _mem_tick "$@"; exit $?;;
    --gate) _mem_gate; exit 0;;
    clean)
      shift
      snap="$(_mem_snapshot --footprint)" || { printf '%s\n' "$snap"; exit 1; }
      _mem_clean "$snap" "$@"; exit $?;;
    watch)
      case "${2:-status}" in
        on) shift 2; _mem_watch_on "$@"; exit $?;;
        off) _mem_watch_off; exit 0;;
        status) _mem_watch_status; exit 0;;
        *) echo "usage: swap-guard mem watch on|off|status" >&2; exit 2;;
      esac;;
    autoclean|gate|nudge)
      local off="$MEM_CLEAN_OFF" key=autoClean
      [ "$sub" = gate ] && { off="$MEM_GATE_OFF"; key=agentGate; }
      [ "$sub" = nudge ] && { off="$MEM_NUDGE_OFF"; key=sessionNotes; }
      case "${2:-status}" in
        on) rm -f "$off";;
        off) mkdir -p "$STATE_ROOT" && touch "$off";;
        status) :;;
        *) echo "usage: swap-guard mem $sub on|off|status" >&2; exit 2;;
      esac
      jq -cn --arg k "$key" --argjson on "$([ -f "$off" ] && echo false || echo true)" '{($k): $on}'; exit 0;;
  esac
  for a in "$@"; do
    case "$a" in
      --text) text=1; fp=1;;
      --footprint) fp=1;;
      --refresh) :;;
      *) echo "usage: swap-guard mem [--text] [--footprint] | clean [--dry-run] [--pid N] | watch on|off|status | autoclean|nudge|gate on|off|status" >&2; exit 2;;
    esac
  done
  if [ "$fp" -eq 1 ]; then snap="$(_mem_snapshot --footprint)"; else snap="$(_mem_snapshot)"; fi || { printf '%s\n' "$snap"; exit 1; }
  if [ "$text" -eq 1 ]; then _mem_text "$snap"; else printf '%s\n' "$snap"; fi
  exit 0
}

# --- budget: tell the model when usage crosses a band (UserPromptSubmit hook) ------
# ULTRACODE's budget-pressure rules key off "Fable ~75%+", a number the model cannot
# see. `budget --hook` injects one line when the active account crosses a band
# (scoped/Fable 75/90/100, week 85/95/100, 5h 90/100) and one when pressure clears.
# Silent otherwise — at most one note per band change per session.
_budget_eval() { # prints: key<US>pressure-message<US>cleared-message
  local seq="$STATE_ROOT/sequence.json" cache="$STATE_ROOT/cache/usage.json"
  [ -r "$seq" ] || return 1
  [ -r "$cache" ] || cache=/dev/null
  jq -rn --slurpfile s "$seq" --slurpfile c "$cache" --argjson now "$(date +%s)" "$JQ_EPOCH"'
    def band($p; $ts): ([$ts[] | select($p >= .)] | max) // 0;
    def eff($w): if $w == null then null
      elif (($w.resets_at // "") | ep) as $r | ($r != null and $r <= $now) then 0
      else ($w.pct // null) end;
    def when($w): (($w.resets_at // "") | ep) as $r
      | if $r == null or $r <= $now then "" else " (resets \($r | strflocaltime("%a %H:%M")))" end;
    def p0: if . == null then "n/a" else "\(. | floor)%" end;
    ($s[0] // {}) as $seq | (($c[0] // {}).accounts // {}) as $acc
    | (($seq.activeAccountNumber // "") | tostring) as $n
    | def lbl($k): ($seq.accounts[$k] // {}) | (.alias // ((.email // "?") | split("@")[0]));
      def wins($k): (($acc[$k].lastGood) // {}) as $g
        | [{name: "5h", w: $g.five_hour, t: [90, 100]},
           {name: "week", w: $g.seven_day, t: [85, 95, 100]}]
          + [$g.scoped[]? | {name, w: ., t: [75, 90, 100]}]
        | map(. + {p: eff(.w)} | . + {b: (if .p == null then 0 else band(.p; .t) end)});
    if $n == "" then empty else
    wins($n) as $ws
    | ($ws | map(select(.b > 0))) as $hot
    | ($n + "|" + ($ws | map("\(.name)=\(.b)") | join(","))) as $key
    | ([$seq.accounts | keys[] | select(. != $n)
        | select(((($acc[.].lastError // "") == "invalid_grant") or (($acc[.].authDeadStrikes // 0) > 0)) | not)
        | select((($seq.accounts[.].disabled // false) or (($seq.accounts[.].kind // "") == "api_key")) | not)
        | select($acc[.].lastGood != null)
        | . as $k | {k: $k, ws: wins($k)}
        | [.ws[] | select(.name == "5h" or .name == "week" or (.name as $nm | $hot | any(.name == $nm)))] as $rel
        | select(($rel | length) > 0 and ($rel | all(.p != null)))
        | . + {worst: ($rel | map(.p) | max)}]
       | sort_by(.worst) | first) as $alt
    | (if ($hot | length) == 0 then "" else
        "[overclaude budget] Account \(lbl($n)): "
        + ($hot | map("\(.name) \(.p | p0) used\(when(.w))") | join(", ")) + ". "
        + (if ($hot | any(.b >= 100 and (.name == "5h" or .name == "week")))
           then "This account is out of quota until the reset: every call will fail. "
           elif ($hot | any(.b >= 100))
           then "\($hot | map(select(.b >= 100) | .name) | join("/")) is exhausted: calls on it will fail, so route agents to opus/sonnet. "
           else "" end)
        + "ULTRACODE budget pressure applies: cut fan-out width and batch more per agent first, keep the scarce tier as a read-only reserve for the final synthesis, and keep verifier floors."
        + (if $alt != null and $alt.worst < 75
           then " Account \(lbl($alt.k)) has room (\($alt.ws | map(select(.name == "week" or (.name as $nm | $hot | any(.name == $nm))) | "\(.name) \(.p | p0)") | join(", "))): the user can /swap \(lbl($alt.k))."
           else "" end)
      end) as $msg
    | ("[overclaude budget] Budget pressure cleared: account \(lbl($n)) is at "
       + ($ws | map("\(.name) \(.p | p0)") | join(", ")) + ". Normal ULTRACODE routing applies.") as $clr
    | [$key, $msg, $clr] | join("\u001f") end' 2>/dev/null
}

cmd_budget() {
  local hook=0 input sid line key msg clr f prev
  for a in "$@"; do [ "$a" = "--hook" ] && hook=1; done
  command -v jq >/dev/null 2>&1 || exit 0
  if [ "$hook" -eq 0 ]; then
    line="$(_budget_eval)" || { printf '%s\n' '{"error":"no-sequence"}'; exit 1; }
    IFS=$'\x1f' read -r key msg clr <<EOF
$line
EOF
    jq -cn --arg k "$key" --arg m "$msg" '{key:$k, pressure:($m != ""), message:(if $m == "" then null else $m end)}'
    exit 0
  fi
  exec 2>/dev/null
  input="$(cat)" || input=""
  # A token job (swap-guard token run) bills another account: these numbers aren't its.
  [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || exit 0
  sid="$(printf '%s' "$input" | jq -r '.session_id // empty' | tr -cd 'A-Za-z0-9_-')"
  [ -n "$sid" ] || exit 0
  # The account changed since this session's last prompt (instant swap elsewhere,
  # /swap, auto-swap, cswap switch): tell it which of its artifacts stay behind.
  local acct now_slot now_email prev_slot af
  acct="$(_active_account_json)"; now_slot="$(printf '%s' "$acct" | jq -r '.slot // empty')"
  now_email="$(printf '%s' "$acct" | jq -r '.email // empty')"
  af="$CTX_DIR/$sid.acct"; prev_slot="$(cat "$af" 2>/dev/null)"
  if [ -n "$now_slot" ]; then
    if [ -n "$prev_slot" ] && [ "$prev_slot" != "$now_slot" ]; then
      _art_switch_note "$sid" "$(_label_for "$prev_slot")" "$(_label_for "$now_slot")" "$now_email"
    fi
    # Rewritten every prompt: the 7-day pruning must not forget a long-lived session.
    mkdir -p "$CTX_DIR" && printf '%s\n' "$now_slot" > "$af"
  fi
  line="$(_budget_eval)" || exit 0
  [ -n "$line" ] || exit 0
  IFS=$'\x1f' read -r key msg clr <<EOF
$line
EOF
  f="$CTX_DIR/$sid.budget"
  # Per-session state for sessions long gone is pruned as we go.
  find "$CTX_DIR" -maxdepth 1 \( -name '*.budget' -o -name '*.rlwake' -o -name '*.retry' -o -name '*.loop' -o -name '*.acct' -o -name '*.ckpt' -o -name '*.mem' \) -mtime +7 -delete 2>/dev/null
  prev="$(cat "$f" 2>/dev/null)"
  [ "$key" = "$prev" ] && exit 0
  if [ -n "$msg" ]; then
    # Out of quota with no Claude account to swap to: Codex is the next place to work.
    # Only when the whole account is out (5h/week at 100%, not one model bucket) and no
    # other Claude account was suggested — and Codex can actually take the work.
    case "$msg" in *"/swap "*) : ;; *"until the reset"*)
      [ "$(cmd_codex_ready 2>/dev/null | jq -r '.ready // false' 2>/dev/null)" = true ] &&
        msg="$msg No Claude account has room; if the user wants to keep going now, /handoff codex continues this work in the Codex CLI.";; esac
    printf '%s\n' "$msg"
  elif [ -n "$prev" ] && printf '%s' "$prev" | grep -q '=[1-9]'; then
    printf '%s\n' "$clr"
  fi
  mkdir -p "$CTX_DIR" && printf '%s\n' "$key" > "$f.tmp.$$" && mv -f "$f.tmp.$$" "$f"
  exit 0
}

# --- artifact copies: copy-on-write clones (B3) ----------------------------------
# On APFS `cp -c` makes a clone: identical content shares disk blocks, but each copy is
# its own file, so editing one (e.g. before republishing) never changes another. Other
# filesystems fall back to a plain copy. No size cap here — a handoff snapshot must keep
# every file the next session needs to republish (artifacts go up to ~16 MB).
_store_copy() { # _store_copy <src> <dst>
  mkdir -p "$(dirname "$2")" || return 1
  cp -c -p "$1" "$2" 2>/dev/null || cp -p "$1" "$2"
}

# --- artifact registry: one list of every artifact, across sessions and accounts ---
# A PostToolUse hook on the Artifact tool (`artifact-log`) appends one record per
# publish: url, title, source path, and the account that owns it (the account active
# at publish time — artifacts are owned server-side by the publishing account). The
# latest page source is copied to a durable dir, since sources usually live in a
# session scratchpad that disappears.
ART_INDEX_DIR="$STATE_ROOT/artifacts"
ART_INDEX="$ART_INDEX_DIR/index.jsonl"
ART_SRC_MAX=16777216  # registry keeps page sources up to 16 MB (the artifact page limit)

_active_account_json() { # {slot, email, alias} of the active account (sequence.json, ~/.claude.json fallback)
  local slot
  slot="$(jq -r '(.activeAccountNumber // empty) | tostring' "$STATE_ROOT/sequence.json" 2>/dev/null)"
  if [ -n "$slot" ]; then
    jq -c --arg n "$slot" '{slot: $n, email: (.accounts[$n].email // null), alias: (.accounts[$n].alias // null)}' \
      "$STATE_ROOT/sequence.json" 2>/dev/null && return 0
  fi
  jq -c '{slot: null, email: (.oauthAccount.emailAddress // null), alias: null}' "$HOME/.claude.json" 2>/dev/null ||
    printf '%s\n' '{"slot":null,"email":null,"alias":null}'
}

cmd_artifact_log() {
  local input rec aid src dst size
  exec 2>/dev/null
  command -v jq >/dev/null 2>&1 || exit 0
  input="$(cat)" || exit 0
  rec="$(printf '%s' "$input" | jq -c --argjson acct "$(_active_account_json)" --argjson now "$(date +%s)" '
    select((.tool_name // "") == "Artifact")
    | (.tool_input // {}) as $in
    | select(($in.action // "publish") == "publish" and (($in.asset // false) | not))
    | (.tool_response // {}) as $r
    | (if ($r | type) == "object" then $r
       elif ($r | type) == "string" then
         {url: ($r | try (capture("(?<u>https://claude\\.ai/(code/)?artifact/[A-Za-z0-9_-]+)").u) catch null)}
       else {} end) as $o
    | select(($o.url // "") != "")
    | {ts: $now, kind: "publish",
       artifact_id: ($o.artifact_id // ($o.url | sub(".*/artifact/"; ""))),
       url: $o.url, title: ($o.title // $in.title // null),
       path: ($in.file_path // $o.path // null),
       account: $acct.email, slot: $acct.slot, alias: $acct.alias,
       sessionId: (.session_id // null), cwd: (.cwd // null)}' 2>/dev/null)" || exit 0
  [ -n "$rec" ] || exit 0
  mkdir -p "$ART_INDEX_DIR" || exit 0
  aid="$(printf '%s' "$rec" | jq -r '.artifact_id' | tr -cd 'A-Za-z0-9_-')"
  src="$(printf '%s' "$rec" | jq -r '.path // empty')"
  if [ -n "$aid" ] && [ -n "$src" ] && [ -f "$src" ] && [ ! -L "$src" ]; then
    size="$(stat -f %z "$src" 2>/dev/null || echo 0)"
    if [ "$size" -le "$ART_SRC_MAX" ] 2>/dev/null; then
      mkdir -p "$ART_INDEX_DIR/src/$aid" && dst="$ART_INDEX_DIR/src/$aid/${src##*/}" &&
        _store_copy "$src" "$dst" && touch "$ART_INDEX_DIR/src/$aid" &&
        rec="$(printf '%s' "$rec" | jq -c --arg d "$dst" '. + {saved: $d}')"
      # Sources of artifacts not published again for 180 days are pruned.
      find "$ART_INDEX_DIR/src" -mindepth 1 -maxdepth 1 -type d -mtime +180 -exec rm -rf {} + 2>/dev/null
    fi
  fi
  printf '%s\n' "$rec" >> "$ART_INDEX"
  exit 0
}

# _art_catalog — the index folded to one entry per artifact (latest publish wins),
# with first-seen time, owner, saved source, and republish links in both directions.
_art_catalog() {
  [ -s "$ART_INDEX" ] || { printf '[]\n'; return 0; }
  # A torn line (hook killed mid-write) must not hide every other record.
  jq -R 'fromjson? // empty' "$ART_INDEX" 2>/dev/null | jq -sc '
    (map(select(.kind == "link"))) as $links
    | map(select(.kind == "publish" and (.artifact_id // "") != ""))
    | group_by(.artifact_id)
    | map((sort_by(.ts)) as $g | ($g | first) as $c
          | ($g | last) + {firstSeen: $c.ts, publishes: ($g | length),
              account: $c.account, slot: $c.slot, alias: $c.alias, inferred: ($c.inferred // false),
              lastPublishedBy: (($g | last) | .alias // .account),
              saved: ([$g[] | .saved // empty] | last // null)})
    # Links name artifacts by URL slug or by id: tool responses carry a UUID
    # artifact_id while URLs end in a short slug, so match either and report URLs.
    | map(. + {slug: ((.url // "") | sub(".*/artifact/"; "") | sub("[?#].*"; ""))})
    | (reduce .[] as $e ({}; . + {($e.artifact_id): $e.url, ($e.slug): $e.url})) as $urlOf
    | map(. as $e
          | . + {republishedAs: ([$links[] | select(.from == $e.artifact_id or .from == $e.slug) | .to] | last
                                 | if . == null then null else ($urlOf[.] // .) end),
                 republishOf: ([$links[] | select(.to == $e.artifact_id or .to == $e.slug) | .from] | last
                                 | if . == null then null else ($urlOf[.] // .) end)}
          | del(.slug))
    | sort_by(-.ts)' 2>/dev/null || printf '[]\n'
}

# --- carry: this session's artifacts after an account change (copy when needed) -----
# An artifact belongs to the account that published it: after a switch the session
# can't update it in place. Nothing is copied up front (most artifacts are scratch
# work): the session is told which of ITS artifacts belong to the old account, and
# only when it needs to change one again does it publish that as a new artifact on
# the account it is on now, from the saved source, and link old → new. Artifacts from
# other sessions are never touched.
_art_session_foreign() { # _art_session_foreign <sid> <active email> — JSON array to carry
  local sid="$1" me ids
  me="$(printf '%s' "${2:-}" | tr '[:upper:]' '[:lower:]')"
  [ -n "$sid" ] && [ -s "$ART_INDEX" ] || { echo '[]'; return 0; }
  ids="$(jq -R 'fromjson? // empty' "$ART_INDEX" 2>/dev/null |
    jq -sc --arg s "$sid" '[.[] | select(.kind == "publish" and .sessionId == $s) | .artifact_id] | unique' 2>/dev/null)"
  [ -n "$ids" ] || ids='[]'
  # Most sessions published nothing: skip the catalog (it folds the whole index).
  [ "$ids" = '[]' ] && { echo '[]'; return 0; }
  _art_catalog | jq -c --argjson ids "$ids" --arg me "$me" \
    --argjson acc "$(jq -c '[.accounts // {} | to_entries[] | {key: ((.value.email // "") | ascii_downcase), value: (.value.alias // null)}] | from_entries' "$STATE_ROOT/sequence.json" 2>/dev/null || echo '{}')" '
    [ .[] | select(.artifact_id as $a | $ids | index($a) != null)
      # An owner the backfill could not infer is not "another account".
      | select((.account | type) == "string" and $me != "" and ((.account | ascii_downcase) != $me))
      | select(.republishedAs == null)
      | {url, title: (.title // null), saved: (.saved // null), path: (.path // null),
         owner: ($acc[(.account | ascii_downcase)] // .alias // .account)} ]' 2>/dev/null || echo '[]'
}

_art_switch_note() { # _art_switch_note <sid> <from label> <to label> <to email> — the note, or nothing (rc 1)
  local list n shown owners
  list="$(_art_session_foreign "$1" "$4")"
  n="$(printf '%s' "$list" | jq 'length' 2>/dev/null)"
  [ "${n:-0}" -gt 0 ] 2>/dev/null || return 1
  shown="$(printf '%s' "$list" | jq -r '[.[:6][] | "\(.title // "untitled" | gsub("[\u0000-\u001f\u007f\u2028\u2029]"; "")) (\(.url))"] | join("; ")')"
  [ "$n" -gt 6 ] && shown="$shown; and $(( n - 6 )) more"
  # Usually the account just left; with 3+ accounts some may belong to another one.
  owners="$(printf '%s' "$list" | jq -r '[.[].owner | tostring | gsub("[\u0000-\u001f\u007f\u2028\u2029]"; "")] | unique | join(", ")')"
  printf 'overclaude: this session moved from %s to %s. %s artifact(s) this session published belong to %s and cannot be updated from %s: %s. Do not copy them now. Only if you need to change one of them again: run `swap-guard artifacts carry` (it lists each with its saved source), publish that source as a NEW artifact on %s, then run `swap-guard artifacts link <old-url> <new-url>` and give the user the new link. Never publish to the old URL.\n' \
    "$2" "$3" "$n" "${owners:-$2}" "$3" "$shown" "$3"
}

_art_carry() { # [--session <id>] — this session's artifacts owned by another account, with sources
  local sid="" acct list out="[]" row url src
  while [ $# -gt 0 ]; do case "$1" in --session) sid="${2:-}"; shift;; esac; shift; done
  [ -n "$sid" ] || sid="$(whoami_json 2>/dev/null | jq -r '.sessionId // empty' 2>/dev/null)"
  sid="$(printf '%s' "$sid" | tr -cd 'A-Za-z0-9_-')"
  [ -n "$sid" ] || { jq -cn '{error:"no-session", detail:"run it from inside a Claude Code session, or pass --session <id>"}'; return 1; }
  acct="$(_active_account_json)"
  list="$(_art_session_foreign "$sid" "$(printf '%s' "$acct" | jq -r '.email // empty')")"
  # The source to republish from: the copy saved at its last publish, else the
  # original file if it is still there.
  while IFS= read -r row; do
    [ -n "$row" ] || continue
    src=""
    for url in "$(printf '%s' "$row" | jq -r '.saved // empty')" "$(printf '%s' "$row" | jq -r '.path // empty')"; do
      [ -n "$url" ] && [ -f "$url" ] && { src="$url"; break; }
    done
    out="$(printf '%s' "$out" | jq -c --argjson r "$row" --arg s "$src" '. + [($r | {url, title, owner})
      + (if $s != "" then {source: $s} else {source: null, note: "no saved source: rebuild it from the conversation"} end)]')"
  done <<EOF
$(printf '%s' "$list" | jq -c '.[]' 2>/dev/null)
EOF
  jq -cn --argjson a "$acct" --argjson l "$out" '{account: ($a.alias // $a.email), artifacts: $l}'
}


_art_id() { printf '%s' "$1" | sed 's|.*/artifact/||; s|[?#].*||' | tr -cd 'A-Za-z0-9_-'; }

_art_find() { # _art_find [--account X] [words...]
  local acct="" words=()
  while [ $# -gt 0 ]; do
    case "$1" in --account) acct="${2:-}"; shift;; *) words+=("$1");; esac
    shift
  done
  _art_catalog | jq -c --arg acct "$acct" --arg q "${words[*]+${words[*]}}" '
    ($q | ascii_downcase | split(" ") | map(select(. != ""))) as $ws
    | map(select($acct == "" or ((.alias // "") == $acct) or ((.account // "") == $acct) or ((.slot // "") == $acct)))
    | map(select(([.title, .url, ((.path // "") | split("/") | last), .artifact_id] | map(. // "") | join(" ") | ascii_downcase) as $h
                 | all($ws[]; . as $w | $h | contains($w))))
    | .[:20]
    | map({artifact_id, url, title, owner: (.alias // .account), ownerEmail: .account,
           updated: (.ts | todate), firstSeen: (.firstSeen | todate), publishes,
           lastPublishedBy, saved, inferredOwner: (.inferred // false), republishedAs, republishOf})'
}

_art_link() { # _art_link <old url|id> <new url|id> — record that <new> republished <old>
  local from to
  from="$(_art_id "${1:-}")"; to="$(_art_id "${2:-}")"
  if [ -z "$from" ] || [ -z "$to" ] || [ "$from" = "$to" ]; then
    echo "usage: swap-guard artifacts link <old-url|id> <new-url|id>" >&2; return 1
  fi
  mkdir -p "$ART_INDEX_DIR" || return 1
  jq -cn --argjson ts "$(date +%s)" --arg f "$from" --arg t "$to" '{ts:$ts, kind:"link", from:$f, to:$t}' >> "$ART_INDEX"
  jq -cn --arg f "$from" --arg t "$to" '{linked:{from:$f, to:$t}}'
}

# _art_backfill — add artifacts published before the hook existed, from every
# transcript. The owner is inferred from cswap's switch log (the account active at
# publish time) and marked inferred:true; entries already indexed are skipped.
_art_backfill() {
  local tmp sw added f
  tmp="$(mktemp)"; sw="$(mktemp)"
  # cswap log lines: "2026-07-19 20:18:40,385 - INFO - Switched from account 2 to 1" (local time)
  grep -E ' - INFO - (Switched from account [0-9]+ to [0-9]+|Added account [0-9]+)' "$STATE_ROOT/claude-swap.log" 2>/dev/null |
    while IFS= read -r l; do
      local d to e
      d="$(printf '%s' "$l" | cut -c1-19)"
      to="$(printf '%s' "$l" | sed -nE 's/.*(Switched from account [0-9]+ to |Added account )([0-9]+).*/\2/p')"
      e="$(date -j -f '%Y-%m-%d %H:%M:%S' "$d" +%s 2>/dev/null)" || continue
      printf '{"t":%s,"to":"%s"}\n' "$e" "$to"
    done > "$sw"
  find "$HOME/.claude/projects" -name '*.jsonl' -type f 2>/dev/null | while IFS= read -r f; do
    grep -q '"artifact_id"' "$f" 2>/dev/null || continue
    jq -c '
      if .type == "assistant" then
        (.message.content[]? | select(.type == "tool_use" and .name == "Artifact")
         | {k: "use", id, action: (.input.action // "publish"), asset: (.input.asset // false), file_path: (.input.file_path // null)})
      elif .type == "user" and (.toolUseResult | type) == "object"
           and ((.toolUseResult.url // "") | test("^https://claude\\.ai/(code/)?artifact/")) then
        {k: "res", id: (.message.content[0].tool_use_id // null), ts: (.timestamp // ""),
         sid: (.sessionId // null), cwd: (.cwd // null),
         r: (.toolUseResult | {url, artifact_id, title, path})}
      else empty end' "$f" 2>/dev/null
  done > "$tmp"
  mkdir -p "$ART_INDEX_DIR" || { rm -f "$tmp" "$sw"; return 1; }
  touch "$ART_INDEX"
  local seqf="$STATE_ROOT/sequence.json"; [ -r "$seqf" ] || seqf=/dev/null
  added="$(jq -sc --slurpfile sw "$sw" --slurpfile seq "$seqf" --slurpfile have <(jq -R 'fromjson? // empty | select(.kind == "publish") | .artifact_id' "$ART_INDEX" 2>/dev/null) '
    ($have) as $known | ($sw | sort_by(.t)) as $switches | (($seq[0] // {}).accounts // {}) as $accts
    | (map(select(.k == "use")) | map({key: (.id // ""), value: .}) | from_entries) as $uses
    | map(select(.k == "res") | . as $x | ($uses[$x.id // ""] // {}) as $u
          | select(($u.action // "publish") == "publish" and (($u.asset // false) | not))
          | ($x.ts | sub("\\.[0-9]+Z$"; "Z") | try fromdateiso8601 catch null) as $t
          | select($t != null)
          | ([$switches[] | select(.t <= $t)] | last | .to // null) as $slot
          | {ts: $t, kind: "publish",
             artifact_id: ($x.r.artifact_id // ($x.r.url | sub(".*/artifact/"; ""))),
             url: $x.r.url, title: $x.r.title, path: ($x.r.path // $u.file_path),
             account: (if $slot then $accts[$slot].email else null end), slot: $slot,
             alias: (if $slot then $accts[$slot].alias else null end),
             sessionId: $x.sid, cwd: $x.cwd, inferred: true})
    | map(select(.artifact_id as $id | ($known | index($id)) | not))
    | unique_by([.artifact_id, .ts]) | .[]' "$tmp" 2>/dev/null)"
  [ -n "$added" ] && printf '%s\n' "$added" >> "$ART_INDEX"
  rm -f "$tmp" "$sw"
  jq -cn --argjson n "$(printf '%s' "$added" | grep -c . || true)" --arg i "$ART_INDEX" '{backfilled:$n, index:$i}'
}

# --- login: safe re-login / add, without killing the live account's token --------
# Refresh tokens rotate on every use and are single-use. A plain `/login` while
# account A is live overwrites A's newest token before cswap has copied it, so A's
# stored copy is already spent and A dies the first time it is polled as an idle
# account — re-logging B kills A, and the accounts die in turn. So:
#   1. `cswap add` (no slot) re-captures the LIVE account's newest token first;
#   2. the sign-in happens in a throwaway CLAUDE_CONFIG_DIR, whose keychain item is
#      keyed to that dir, so the live login is never touched;
#   3. that profile is registered into the slot, `cswap add` re-anchors cswap on the
#      live account, and the throwaway dir + its keychain item are deleted.
cmd_login() {
  local raw="" new=0 slot="" email="" tmp svc st got live a profile_arg="" bline bdir="" bname="" bhow="" opener=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --new) new=1;;
      --profile) profile_arg="${2:-}"; shift;;
      --profile=*) profile_arg="${1#--profile=}";;
      -*) :;;
      *) [ -z "$raw" ] && raw="$1";;
    esac
    shift
  done
  command -v cswap >/dev/null 2>&1 || { echo '{"error":"cswap-not-found"}'; exit 1; }
  command -v claude >/dev/null 2>&1 || { echo '{"error":"claude-not-found"}'; exit 1; }
  if [ "$new" -eq 0 ]; then
    [ -n "$raw" ] || { echo "usage: swap-guard login <slot|alias|email> | --new" >&2; exit 2; }
    case "$raw" in
      *[!0-9]*) slot="$(jq -r --arg a "$raw" '[.accounts | to_entries[]
                  | select((.value.alias // "") == $a or ((.value.email // "") | ascii_downcase) == ($a | ascii_downcase))
                  | .key] | if length == 1 then .[0] elif length > 1 then "AMBIGUOUS" else empty end' \
                  "$STATE_ROOT/sequence.json" 2>/dev/null)"
                if [ "$slot" = AMBIGUOUS ]; then
                  jq -cn --arg t "$raw" '{error:"ambiguous-target", target:$t, detail:"several slots match (same email in two orgs?) — pass the slot number"}'
                  exit 1
                fi;;
      *) slot="$raw";;
    esac
    email="$(jq -r --arg n "$slot" '.accounts[$n].email // empty' "$STATE_ROOT/sequence.json" 2>/dev/null)"
    [ -n "$slot" ] && [ -n "$email" ] || { jq -cn --arg t "$raw" '{error:"unknown-target", target:$t}'; exit 1; }
  fi
  local list_json
  list_json="$(cswap list --json 2>/dev/null)"
  if ! printf '%s' "$list_json" | jq -e '.accounts' >/dev/null 2>&1; then
    # Without it we cannot tell which account is live, so we could neither protect
    # nor re-anchor it. Stop before anything changes.
    jq -cn '{error:"cswap-list-failed", detail:"cswap list --json failed — run: cswap list (to see why), then retry"}'; exit 1
  fi
  live="$(printf '%s' "$list_json" | jq -r '[.accounts[]? | select(.active == true) | .number] | first // empty' 2>/dev/null)"
  if [ "$new" -eq 0 ] && [ "$slot" = "$live" ]; then
    # Re-logging the live account itself: the live keychain item IS the target.
    echo "Account $slot is the live account: sign in with /login inside Claude Code, then run: cswap add" >&2
    jq -cn --arg s "$slot" '{error:"target-is-live", slot:$s}'; exit 1
  fi
  echo "1/4 saving the live account's newest token (cswap add)…" >&2
  if [ -n "$live" ] && ! cswap add >/dev/null 2>&1; then
    echo "    could not re-capture the live account; stopping so nothing gets overwritten." >&2
    jq -cn '{error:"capture-failed"}'; exit 1
  fi
  mkdir -p "$STATE_ROOT" || exit 1
  tmp="$(mktemp -d "$STATE_ROOT/login.XXXXXX")" || exit 1
  # Claude Code keys the profile's keychain item by sha256 of the NFC-normalized dir
  # string; macOS paths can arrive decomposed (NFD), so normalize before hashing.
  svc="Claude Code-credentials-$( { printf '%s' "$tmp" | iconv -f UTF-8-MAC -t UTF-8 2>/dev/null || printf '%s' "$tmp"; } |
    /usr/bin/shasum -a 256 | cut -c1-8)"
  trap 'rm -rf "$tmp"; security delete-generic-password -s "$svc" >/dev/null 2>&1' EXIT
  echo "2/4 a browser window opens — sign in as ${email:-the new account}…" >&2
  # The Chrome profile for this account (browser set, or the one signed in to the
  # same address): the sign-in page opens there. --profile picks one for this run.
  if [ -n "$profile_arg" ]; then
    bline="$(_chrome_profiles | jq -r --arg p "$profile_arg" 'first(.[] | select(.dir == $p or (.name | ascii_downcase) == ($p | ascii_downcase))) | [.dir, .name, "--profile"] | join("\t")' 2>/dev/null)"
    [ -n "$bline" ] || echo "    no Chrome profile '$profile_arg' (swap-guard browser list); using the default browser" >&2
  elif [ -n "$email" ]; then
    bline="$(_browser_for "$email")"
  fi
  if [ -n "${bline:-}" ] && [ -z "${SWAP_NO_CHROME_PROFILE:-}" ] && _chrome_installed; then
    IFS="$(printf '\t')" read -r bdir bname bhow <<EOF
$bline
EOF
    if [ -n "$bdir" ] && _browser_opener "$bdir" "$tmp/open-browser"; then
      opener="$tmp/open-browser"
      echo "    opening it in Chrome profile \"$bname\" ($bhow)" >&2
    fi
  fi
  local login_env=(CLAUDE_CONFIG_DIR="$tmp"); [ -n "$opener" ] && login_env+=(BROWSER="$opener")
  if [ -n "$email" ]; then
    env "${login_env[@]}" claude auth login --email "$email" >&2 || { jq -cn '{error:"login-failed"}'; exit 1; }
  else
    env "${login_env[@]}" claude auth login >&2 || { jq -cn '{error:"login-failed"}'; exit 1; }
  fi
  st="$(CLAUDE_CONFIG_DIR="$tmp" claude auth status 2>/dev/null)"
  got="$(printf '%s' "$st" | jq -r '.email // .account.email // empty' 2>/dev/null)"
  if [ -n "$email" ] && [ "$(printf '%s' "$got" | tr '[:upper:]' '[:lower:]')" != "$(printf '%s' "$email" | tr '[:upper:]' '[:lower:]')" ]; then
    jq -cn --arg want "$email" --arg got "$got" '{error:"wrong-account", expected:$want, signedInAs:$got,
      detail:"nothing was changed; run it again and sign in with the expected account"}'
    exit 1
  fi
  echo "3/4 registering ${email:-$got} in cswap…" >&2
  # cswap asks "Overwrite slot N? [y/N]" when the stored org differs; with no terminal
  # it prints "Cancelled" and still exits 0 — so success is proven by sequence.json
  # changing, not by the exit code.
  local before after addout keys_before retry
  before="$(jq -r '.lastUpdated // empty' "$STATE_ROOT/sequence.json" 2>/dev/null)"
  keys_before="$(jq -c '.accounts | keys' "$STATE_ROOT/sequence.json" 2>/dev/null)"
  local add_args=(add); [ -n "$slot" ] && add_args+=(--slot "$slot")
  if [ -t 0 ]; then
    # A terminal: cswap's own y/N (e.g. an organization change) reaches the user.
    # Its output is not captured here, so the prompt is visible.
    CLAUDE_CONFIG_DIR="$tmp" cswap "${add_args[@]}" >&2 || { jq -cn '{error:"cswap-add-failed"}'; exit 1; }
    addout=""
  else
    addout="$(CLAUDE_CONFIG_DIR="$tmp" cswap "${add_args[@]}" </dev/null 2>&1)" || { jq -cn --arg d "$addout" '{error:"cswap-add-failed", detail:$d}'; exit 1; }
    printf '%s\n' "$addout" >&2
  fi
  after="$(jq -r '.lastUpdated // empty' "$STATE_ROOT/sequence.json" 2>/dev/null)"
  if printf '%s' "$addout" | grep -qi 'cancelled' || [ "$before" = "$after" ]; then
    retry="swap-guard login ${slot:---new}"
    jq -cn --arg s "${slot:-new}" --arg r "$retry" --arg d "$(printf '%s' "$addout" | tail -c 300)" '{error:"cswap-add-cancelled", slot:$s,
      detail:("cswap did not register the account — it needs a y/N confirmation (usually an organization change) that only a terminal can answer. Run in a terminal: " + $r + "  — " + $d)}'
    exit 1
  fi
  # --new: report the slot cswap actually assigned (the one key that appeared).
  if [ -z "$slot" ]; then
    slot="$(jq -r --argjson b "${keys_before:-[]}" '(.accounts | keys) - $b | first // empty' "$STATE_ROOT/sequence.json" 2>/dev/null)"
  fi
  echo "4/4 re-anchoring cswap on the live account and cleaning up…" >&2
  local reanchored=true
  # A new sign-in starts a new login: that account's cached end date is wrong now
  # (only that one — the others' dates still stand if a later keychain read fails).
  if [ -n "${slot:-}" ] && [ -f "$LOGINS_CACHE" ]; then
    jq -c --arg n "$slot" '.accounts |= map(select(.slot != $n))' "$LOGINS_CACHE" > "$LOGINS_CACHE.tmp.$$" 2>/dev/null &&
      mv "$LOGINS_CACHE.tmp.$$" "$LOGINS_CACHE" || rm -f "$LOGINS_CACHE.tmp.$$"
  fi
  if [ -n "$live" ] && ! cswap add </dev/null >/dev/null 2>&1; then reanchored=false; fi
  [ -z "$live" ] && reanchored=false
  jq -cn --arg s "${slot:-new}" --arg e "${email:-$got}" --arg l "${live:-}" --argjson r "$reanchored" \
    '{loggedIn:$e, slot:$s, live:(if $l == "" then null else $l end), liveUntouched:true, reanchored:$r}
     + (if $r then {} else {detail:"cswap may now think the re-logged account is active — run: cswap add   (while the live account is logged in)"} end)'
  exit 0
}

# --- version-check: is a newer overclaude on PyPI? (at most one request a day) -----
# The statusline only ever READS the cache this writes, and starts this in the
# background when the cache is a day old — it never waits on the network. The
# request is an anonymous GET of PyPI's public JSON; nothing about the user is sent.
cmd_version_check() {
  local force=0 cache="$STATE_ROOT/cache/latest-version.json" checked now latest a k
  for a in "$@"; do [ "$a" = "--force" ] && force=1; done
  now="$(date +%s)"
  checked="$(jq -r '.checkedAt // 0' "$cache" 2>/dev/null)"; case "$checked" in ''|*[!0-9]*) checked=0;; esac
  # A check from before the last install/upgrade is stale: the release that was just
  # installed may postdate it.
  k="$(stat -f %m "$STATE_ROOT/kit-version" 2>/dev/null)"
  case "$k" in ''|*[!0-9]*) ;; *) [ "$k" -gt "$checked" ] && checked=0;; esac
  if [ "$force" -eq 0 ] && [ $(( now - checked )) -lt 86400 ]; then
    jq -c --arg k "$(cat "$STATE_ROOT/kit-version" 2>/dev/null)" '. + {installed: $k, cached: true}' "$cache"; exit 0
  fi
  mkdir -p "$STATE_ROOT/cache" || exit 1
  latest="$(curl -fsS --max-time 5 https://pypi.org/pypi/overclaude/json 2>/dev/null | jq -r '.info.version // empty' 2>/dev/null)"
  if [ -z "$latest" ]; then
    # Record the attempt anyway so an offline machine retries tomorrow, not every render.
    jq -cn --argjson now "$now" --arg v "$(jq -r '.version // empty' "$cache" 2>/dev/null)" \
      '{version: (if $v == "" then null else $v end), checkedAt: $now, error: "unreachable"}' > "$cache.tmp.$$" &&
      mv "$cache.tmp.$$" "$cache"
  else
    jq -cn --argjson now "$now" --arg v "$latest" '{version: $v, checkedAt: $now}' > "$cache.tmp.$$" && mv "$cache.tmp.$$" "$cache"
  fi
  jq -c --arg k "$(cat "$STATE_ROOT/kit-version" 2>/dev/null)" '. + {installed: $k, cached: false}' "$cache"
}

# --- codex: the sibling CLI (overcodex) as another place to keep working -----------
# Codex accounts are separate CODEX_HOMEs: ~/.codex (primary) and, with overcodex's
# codex-swap, ~/.codex-accounts/<name>; ~/.codex-accounts/.active names the one new
# `codex` runs use. Codex records its own rate limits in every session log
# (event_msg/token_count .payload.rate_limits), so usage is read from there — as
# fresh as that home's last Codex turn.
CODEX_ACCOUNTS="$HOME/.codex-accounts"

_codex_active_home() { # the CODEX_HOME a new `codex` would use
  # Resolved from codex-swap's marker exactly as overcodex's codex() wrapper does. An
  # inherited $CODEX_HOME is ignored: overcodex <0.3 exported it into the shell, so after
  # a `codex-swap use` it can name the previous account. OVERCLAUDE_CODEX_HOME overrides.
  local a
  if [ -n "${OVERCLAUDE_CODEX_HOME:-}" ]; then printf '%s\n' "$OVERCLAUDE_CODEX_HOME"; return 0; fi
  a="$(head -n 1 "$CODEX_ACCOUNTS/.active" 2>/dev/null | tr -d '[:space:]')"
  case "$a" in *[!A-Za-z0-9_-]*) a="";; esac
  if [ -n "$a" ] && [ "$a" != primary ] && [ -d "$CODEX_ACCOUNTS/$a" ]; then
    printf '%s\n' "$CODEX_ACCOUNTS/$a"
  else
    printf '%s\n' "$HOME/.codex"
  fi
}

_codex_homes() { # name<TAB>home for every Codex home on this machine
  [ -d "$HOME/.codex" ] && printf 'primary\t%s\n' "$HOME/.codex"
  local d
  for d in "$CODEX_ACCOUNTS"/*/; do
    [ -d "$d" ] || continue
    d="${d%/}"; printf '%s\t%s\n' "${d##*/}" "$d"
  done
}

# _codex_usage_one <name> <home> — JSON for one home's latest rate limits, or nothing.
_codex_usage_one() {
  local name="$1" home="$2" f line mt
  [ -d "$home/sessions" ] || return 0
  # Newest session log that actually carries rate limits: a just-started session has
  # none yet, and one that hit the limit early may log rate_limits: null.
  line=""
  while IFS= read -r f; do
    line="$(tail -n 400 "$f" 2>/dev/null | /usr/bin/grep '"rate_limits":{' | tail -n 1)"
    [ -n "$line" ] && break
  done <<EOF
$(find "$home/sessions" -name 'rollout-*.jsonl' -type f -mtime -8 -exec stat -f '%m %N' {} + 2>/dev/null |
    sort -rn | head -n 20 | cut -d' ' -f2-)
EOF
  [ -n "$line" ] || return 0
  mt="$(stat -f %m "$f" 2>/dev/null || echo 0)"
  printf '%s' "$line" | jq -c --arg n "$name" --arg h "$home" --argjson mt "$mt" \
    --arg active "$(_codex_active_home)" '
    (.payload.rate_limits // .rate_limits // empty) as $rl
    | def win($w): if $w == null then null
        else {pct: ($w.used_percent // null), windowMinutes: ($w.window_minutes // null), resetsAt: ($w.resets_at // null),
              label: (($w.window_minutes // 0) as $m | if $m == 300 then "5h" elif $m == 10080 then "week"
                      elif $m >= 1440 then "\($m / 1440 | floor)d" elif $m > 0 then "\($m / 60 | floor)h" else "?" end)} end;
    {name: $n, home: $h, active: ($h == $active), plan: ($rl.plan_type // null), asOf: $mt,
     limitReached: ($rl.rate_limit_reached_type // null),
     windows: ([win($rl.primary), win($rl.secondary)] | map(select(. != null)))}' 2>/dev/null
}

cmd_codex_ready() { # can a Codex session pick work up here? JSON verdict for /handoff codex
  local home cli=false hook=false limit=null u
  home="$(_codex_active_home)"
  command -v codex >/dev/null 2>&1 && cli=true
  [ -f "$home/hooks/overcodex-handoff-inject.sh" ] && hook=true
  u="$(_codex_usage_one "x" "$home")"
  local known=false; [ -n "$u" ] && known=true
  [ -n "$u" ] && limit="$(printf '%s' "$u" | jq -c --argjson now "$(date +%s)" \
    '[.windows[] | select((.resetsAt // 0) > $now and (.pct // 0) >= 100)] | if length > 0 then .[0] else null end')"
  local pend pf; pf="$(cmd_path codex-handoff 2>/dev/null)"; pend=null
  [ -f "$pf" ] && pend="$(jq -cn --arg p "$pf" --argjson age "$(( $(date +%s) - $(stat -f %m "$pf") ))" '{path:$p, ageSec:$age}')"
  jq -cn --arg home "$home" --argjson cli "$cli" --argjson hook "$hook" --argjson limit "${limit:-null}" \
    --argjson known "$known" --argjson pend "$pend" '
    {home: $home, codexCli: $cli, overcodexHook: $hook, exhausted: $limit, limitsKnown: $known, pending: $pend,
     ready: ($cli and $hook and $limit == null),
     detail: (if ($cli | not) then "Codex CLI not installed (npm i -g @openai/codex)"
              elif ($hook | not) then "overcodex is not installed in this Codex home, so nothing would load the package: pipx install overcodex && overcodex install"
              elif $limit != null then "this Codex account is at its \($limit.label) limit"
              else "ok" end)}'
}

# cmd_codex_review — a cross-family second opinion for ULTRACODE verify stages:
# `codex exec` in a READ-ONLY sandbox, ephemeral (no session file), on the active
# Codex account, with a hard timeout. Prompt = args, plus stdin with --stdin (e.g. a
# diff). Prints JSON {ok, message, json?, ...}; --raw prints only the message.
cmd_codex_review() {
  local dir="$PWD" model="" effort="" schema="" timeout=600 raw=0 words=() home out err rc t0 t1 msg stdin_f="" use_stdin=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --dir) dir="${2:-}"; shift;;
      --model) model="${2:-}"; shift;;
      --effort) effort="${2:-}"; shift;;
      --schema) schema="${2:-}"; shift;;
      --timeout) timeout="${2:-600}"; shift;;
      --raw) raw=1;;
      --stdin) use_stdin=1;;
      *) words+=("$1");;
    esac
    shift
  done
  case "$timeout" in ''|*[!0-9]*) timeout=600;; esac
  [ "$timeout" -ge 1 ] || { jq -cn '{ok:false, error:"bad-timeout", detail:"--timeout must be at least 1 second"}'; exit 1; }
  case "$effort" in ''|none|minimal|low|medium|high|xhigh|max) :;; *) jq -cn --arg e "$effort" '{ok:false, error:"bad-effort", effort:$e}'; exit 1;; esac
  case "$model" in *[!A-Za-z0-9._:-]*) jq -cn '{ok:false, error:"bad-model"}'; exit 1;; esac
  command -v codex >/dev/null 2>&1 || { jq -cn '{ok:false, error:"codex-not-installed", detail:"npm i -g @openai/codex"}'; exit 1; }
  [ -d "$dir" ] || { jq -cn --arg d "$dir" '{ok:false, error:"no-such-dir", dir:$d}'; exit 1; }
  if [ -n "$schema" ] && [ ! -f "$schema" ]; then jq -cn --arg s "$schema" '{ok:false, error:"no-such-schema", schema:$s}'; exit 1; fi
  # Only with --stdin: agent and hook shells often hold an open stdin that never
  # closes, so reading it unasked would hang.
  if [ "$use_stdin" -eq 1 ]; then stdin_f="$(mktemp)"; cat > "$stdin_f"; [ -s "$stdin_f" ] || { rm -f "$stdin_f"; stdin_f=""; }; fi
  if [ "${#words[@]}" -eq 0 ] && [ -z "$stdin_f" ]; then
    echo "usage: swap-guard codex-review [--dir D] [--model M] [--effort E] [--schema F] [--timeout S] [--raw] <prompt> [--stdin < input]" >&2; exit 2
  fi
  home="$(_codex_active_home)"
  out="$(mktemp)"; err="$(mktemp)"
  local args=(exec -s read-only --ephemeral --skip-git-repo-check --color never -C "$dir" -o "$out")
  [ -n "$model" ] && args+=(-m "$model")
  [ -n "$effort" ] && args+=(-c "model_reasoning_effort=\"$effort\"")
  [ -n "$schema" ] && args+=(--output-schema "$schema")
  # `--` ends the options: a prompt starting with "-" can never become a Codex flag
  # (e.g. one that loosens the read-only sandbox). With --stdin and no words, the
  # prompt is omitted and Codex reads it from stdin.
  [ "${#words[@]}" -gt 0 ] && args+=(-- "${words[*]}")
  t0="$(date +%s)"
  # perl's alarm is the portable timeout on macOS (no coreutils `timeout`).
  # The subshell keeps bash's "Alarm clock" job notice (on timeout) off the terminal.
  # Timeout kills the whole process group: the npm `codex` wrapper spawns a native
  # binary and forwards only TERM/INT/HUP, so killing the wrapper alone would leave
  # Codex running (and spending quota). exit 142 = timed out.
  CODEX_HOME="$home" perl -e '
    my $t = shift; my $pid = fork; exit 127 unless defined $pid;
    if ($pid == 0) { setpgrp(0, 0); exec @ARGV or exit 127 }
    $SIG{ALRM} = sub { kill "TERM", -$pid; sleep 2; kill "KILL", -$pid; waitpid($pid, 0); exit 142 };
    alarm $t; waitpid($pid, 0); alarm 0;
    my $st = $?; exit(($st & 127) ? 128 + ($st & 127) : $st >> 8)' "$timeout" codex "${args[@]}" \
      < "${stdin_f:-/dev/null}" >/dev/null 2>"$err"; rc=$?
  t1="$(date +%s)"
  msg="$(cat "$out" 2>/dev/null)"
  [ -n "$stdin_f" ] && rm -f "$stdin_f"
  if [ "$raw" -eq 1 ] && [ "$rc" -eq 0 ]; then
    rm -f "$out" "$err"; [ -n "$msg" ] || exit 1; printf '%s\n' "$msg"; exit 0
  fi
  jq -cn --argjson ok "$([ "$rc" -eq 0 ] && [ -n "$msg" ] && echo true || echo false)" --argjson rc "$rc" \
    --arg msg "$msg" --arg home "$home" --arg model "$model" --argjson secs "$(( t1 - t0 ))" \
    --arg err "$(tail -c 600 "$err" 2>/dev/null)" --argjson timeout "$timeout" '
    {ok: $ok, exitCode: $rc, codexHome: $home, model: (if $model == "" then "default" else $model end),
     durationSec: $secs, message: $msg}
    + (($msg | try fromjson catch null) as $j | if $j != null then {json: $j} else {} end)
    + (if $rc == 142 then {error: "timeout", detail: "no answer within \($timeout)s"}
       elif $rc != 0 then {error: "codex-failed", detail: $err} else {} end)'
  rm -f "$out" "$err"
  [ "$rc" -eq 0 ]
}

cmd_codex_usage() { # [--refresh] — cached for 2 minutes (the statusline reads the cache)
  local cache="$STATE_ROOT/cache/codex-usage.json" refresh=0 a now m name home out
  for a in "$@"; do [ "$a" = "--refresh" ] && refresh=1; done
  command -v jq >/dev/null 2>&1 || { printf '[]\n'; exit 0; }
  now="$(date +%s)"; m="$(stat -f %m "$cache" 2>/dev/null || echo 0)"
  if [ "$refresh" -eq 0 ] && [ $(( now - m )) -lt 120 ] && [ -s "$cache" ]; then cat "$cache"; exit 0; fi
  out="$(_codex_homes | while IFS=$'\t' read -r name home; do _codex_usage_one "$name" "$home"; done | jq -sc '.')"
  [ -n "$out" ] || out="[]"
  mkdir -p "$STATE_ROOT/cache" && printf '%s\n' "$out" > "$cache.tmp.$$" && mv "$cache.tmp.$$" "$cache"
  printf '%s\n' "$out"
}

# --- route-guard: ULTRACODE rule 1 as a gate (PreToolUse on Agent|Workflow) --------
# An un-annotated subagent silently inherits the session's model; on Fable that is a
# spend from the small Fable bucket nobody chose. When the session runs on a Fable/
# Mythos-tier model (read from the statusline relay — hooks get no model field):
#   Agent    — no `model`, subagent_type general-purpose/claude/Plan/default → deny.
#              Forks and agent types whose own definition sets a model are allowed.
#   Workflow — every agent() call needs `model:`; a call whose text names fable/apex
#              (e.g. label 'synthesize (fable/xhigh)') is a deliberate inherit.
# Deny = hookSpecificOutput permissionDecision "deny" with a reason Claude reads and
# fixes. Off switch: `swap-guard route-guard off` (or OVERCLAUDE_ROUTE_GUARD=off).
ROUTE_OFF="$STATE_ROOT/route-guard.off"

_route_deny() { # _route_deny <reason>
  jq -cn --arg r "$1" '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
  exit 0
}

_agent_def_file() { # _agent_def_file <type> <cwd> — path of a custom agent definition, if any
  local t="$1" d="${2:-}" f
  case "$t" in ''|*/*|*..*) return 1;; esac
  # Project agents may sit in any parent of the cwd (Claude Code walks up to the repo root).
  while [ -n "$d" ] && [ "$d" != "/" ]; do
    [ -f "$d/.claude/agents/$t.md" ] && { printf '%s\n' "$d/.claude/agents/$t.md"; return 0; }
    d="$(dirname "$d")"
  done
  f="$HOME/.claude/agents/$t.md"; [ -f "$f" ] && { printf '%s\n' "$f"; return 0; }
  return 1
}

_agent_def_has_model() { # _agent_def_has_model <type> <cwd> — does its definition pin a model?
  local t="$1" cwd="$2" f
  case "$t" in ''|*/*|*..*) return 1;; esac
  f="$(_agent_def_file "$t" "$cwd")" || return 1
  awk '{ sub(/\r$/, "") } NR == 1 && $0 != "---" { exit 1 } NR > 1 && $0 == "---" { exit 1 }
       NR > 1 && /^model:[[:space:]]*[^[:space:]]/ && $0 !~ /inherit/ { found = 1; exit 0 }
       END { exit !found }' "$f"
}

# _workflow_unrouted — read a JS script on stdin; print each agent(...) call that is
# not routed (first 100 chars, one per line). Pass 1 builds a same-length copy with
# comments blanked and string / template / regex-literal CONTENTS masked, so text in
# prompts can neither create a call nor satisfy the check. A call is routed when its
# last argument is an object literal with a depth-1 `model` key (also shorthand
# `{ model }`) whose value is not ''/undefined/null — or whose `label` names
# fable/apex (a deliberate top-tier stage). Options passed by variable, or with a
# spread, cannot be seen into and are allowed; so is a call whose parens do not
# close within 20 KB (parse failure fails open, it never blocks).
_workflow_unrouted() {
  perl -e '
    local $/; my $src = <STDIN>; my $n = length $src;
    my ($code, $i, $prev) = ("", 0, "");
    while ($i < $n) {
      my $c = substr($src, $i, 1); my $d = substr($src, $i + 1, 1);
      if ($c eq "/" && $d eq "/") { my $e = index($src, "\n", $i); $e = $n if $e < 0; $code .= " " x ($e - $i); $i = $e; next }
      if ($c eq "/" && $d eq "*") { my $e = index($src, "*/", $i + 2); $e = $e < 0 ? $n : $e + 2;
        (my $seg = substr($src, $i, $e - $i)) =~ s/[^\n]/ /g; $code .= $seg; $i = $e; next }
      if ($c eq "\"" || $c eq "\x27" || $c eq "`") {
        my ($j, $out) = ($i + 1, $c);
        while ($j < $n) { my $x = substr($src, $j, 1);
          if ($x eq "\\") { $out .= "xx"; $j += 2; next }
          last if $x eq $c;
          $out .= ($x eq "\n" ? "\n" : "x"); $j++ }
        if ($j < $n) { $out .= $c; $j++ }
        $code .= $out; $i = $j; $prev = $c; next }
      if ($c eq "/" && ($prev eq "" || $prev =~ /[(,=:\[!&|?{};]/)) {
        my ($j, $out, $cls) = ($i + 1, "/", 0);
        while ($j < $n) { my $x = substr($src, $j, 1);
          if ($x eq "\\") { $out .= "xx"; $j += 2; next }
          last if $x eq "\n";
          if ($cls) { $cls = 0 if $x eq "]" } elsif ($x eq "[") { $cls = 1 } elsif ($x eq "/") { last }
          $out .= "x"; $j++ }
        if ($j < $n && substr($src, $j, 1) eq "/") { $out .= "/"; $j++ }
        $code .= $out; $i = $j; $prev = "/"; next }
      $code .= $c; $prev = $c unless $c =~ /\s/; $i++;
    }
    my @bad;
    while ($code =~ /(?<![\w.\$])agent\s*\(/g) {
      my ($start, $p) = ($-[0], $+[0]); my $depth = 1; my @arg = ($p);
      my $lim = $p + 20000; $lim = $n if $lim > $n;
      while ($p < $lim && $depth > 0) { my $c = substr($code, $p, 1);
        if ($c =~ /[(\[{]/) { $depth++ } elsif ($c =~ /[)\]}]/) { $depth-- }
        elsif ($c eq "," && $depth == 1) { push @arg, $p + 1 }
        $p++ }
      next if $depth > 0;
      my $close = $p - 1;
      my @ends = (map({ $_ - 1 } @arg[1 .. $#arg]), $close);
      my @a = map { [$arg[$_], $ends[$_]] } 0 .. $#arg;
      pop @a if @a > 1 && substr($code, $a[-1][0], $a[-1][1] - $a[-1][0]) =~ /^\s*$/;   # trailing comma
      my $routed = 0;
      if (@a >= 2) {
        my ($s0, $e0) = @{$a[-1]};
        my $t = substr($code, $s0, $e0 - $s0);
        (my $lead) = $t =~ /^(\s*)/; my $b = $s0 + length($lead);
        if (substr($code, $b, 1) ne "{") { $routed = 1 }          # opts by variable or expression
        else {
          my ($flat, $dd) = ("", 0);
          for my $k ($b + 1 .. $e0 - 1) { my $ch = substr($code, $k, 1);
            if ($ch =~ /[\[{(]/) { $dd++; $flat .= " "; next }
            if ($ch =~ /[\]})]/) { last if $dd == 0; $dd--; $flat .= " "; next }
            $flat .= $dd ? " " : $ch }
          if ($flat =~ /\.\.\./) { $routed = 1 }                   # spread: cannot see inside
          elsif ($flat =~ /(?:^|[,\s])model\s*(:|,|$)/g) {
            if ($1 eq ":") { my $v = substr($src, $b + 1 + pos($flat), 40);
              $routed = ($v =~ /^\s*(\x27\x27|""|``|undefined|null)\s*(,|\}|$)/) ? 0 : 1 }
            else { $routed = 1 }                                   # shorthand { model }
          }
          if (!$routed && $flat =~ /(?:^|[,\s])label\s*:/g) {
            my $vs = $b + 1 + pos($flat); my $rest = substr($flat, pos($flat));
            my $len = index($rest, ","); $len = length($rest) if $len < 0;
            $routed = 1 if substr($src, $vs, $len) =~ /fable|apex/i }
        }
      }
      next if $routed;
      (my $one = substr($src, $start, $close - $start + 1)) =~ s/\s+/ /g;
      push @bad, substr($one, 0, 100);
    }
    print "$_\n" for @bad;'
}

cmd_route_guard() {
  local input tool sid cwd model ti st m bad n script sp
  case "${1:-}" in
    on)  rm -f "$ROUTE_OFF"; jq -cn '{routeGuard: true}'; exit 0;;
    off) mkdir -p "$STATE_ROOT" && touch "$ROUTE_OFF"; jq -cn '{routeGuard: false}'; exit 0;;
    status) jq -cn --argjson on "$([ -f "$ROUTE_OFF" ] && echo false || echo true)" '{routeGuard: $on}'; exit 0;;
  esac
  # Run by hand (a terminal, no hook JSON coming): report status instead of waiting.
  if [ -t 0 ]; then jq -cn --argjson on "$([ -f "$ROUTE_OFF" ] && echo false || echo true)" '{routeGuard: $on}'; exit 0; fi
  exec 2>/dev/null
  command -v jq >/dev/null 2>&1 || exit 0
  [ "${OVERCLAUDE_ROUTE_GUARD:-}" = off ] && exit 0
  [ -f "$ROUTE_OFF" ] && exit 0
  input="$(cat)" || exit 0
  tool="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
  sid="$(printf '%s' "$input" | jq -r '.session_id // empty' | tr -cd 'A-Za-z0-9_-')"
  cwd="$(printf '%s' "$input" | jq -r '.cwd // empty')"
  # The session's model: its last assistant entry in the transcript (authoritative and
  # follows /model), else the statusline relay. Unknown → never guess, allow.
  local tp; tp="$(printf '%s' "$input" | jq -r '.transcript_path // empty')"
  model=""
  [ -n "$tp" ] && [ -f "$tp" ] && model="$(tail -n 300 "$tp" 2>/dev/null | /usr/bin/grep '"type":"assistant"' | tail -n 1 |
    jq -r '.message.model // empty' 2>/dev/null)"
  [ -n "$model" ] || model="$(jq -r '.model // empty' "$CTX_DIR/$sid.json" 2>/dev/null)"
  # Only the top tier is guarded: inheriting Sonnet/Opus/Haiku is not a hidden Fable spend.
  case "$(printf '%s' "$model" | tr '[:upper:]' '[:lower:]')" in *fable*|*mythos*) :;; *) exit 0;; esac
  case "$tool" in
    Agent|Task)
      m="$(printf '%s' "$input" | jq -r '.tool_input.model // empty')"
      st="$(printf '%s' "$input" | jq -r '.tool_input.subagent_type // empty')"
      [ -n "$m" ] && exit 0
      case "$st" in
        fork) exit 0;;                              # forks always inherit, by design
        ''|general-purpose|claude|Plan) :;;         # inherit the session model → guarded
        *) # A custom agent file without a model inherits too; other built-in types
           # (Explore, …) carry their own defaults and are left alone.
           _agent_def_file "$st" "$cwd" >/dev/null || exit 0
           _agent_def_has_model "$st" "$cwd" && exit 0;;
      esac
      _route_deny "ULTRACODE rule 1 (overclaude route-guard): this session runs on $model, so an Agent call without \`model\` silently spends the Fable bucket. Set model explicitly — haiku (scouting, extraction), sonnet (finders, scoped edits), opus (verifiers, tricky edits) — or pass model \"fable\" when the top tier is the deliberate choice." ;;
    Workflow)
      script="$(printf '%s' "$input" | jq -r '.tool_input.script // empty')"
      if [ -z "$script" ]; then
        sp="$(printf '%s' "$input" | jq -r '.tool_input.scriptPath // empty')"
        [ -n "$sp" ] && [ -f "$sp" ] && script="$(cat "$sp")"
      fi
      [ -n "$script" ] || exit 0   # a saved workflow by name: its author owns the routing
      bad="$(printf '%s' "$script" | _workflow_unrouted)"
      [ -n "$bad" ] || exit 0
      n="$(printf '%s\n' "$bad" | grep -c .)"
      _route_deny "ULTRACODE rule 1 (overclaude route-guard): this session runs on $model and $n agent() call(s) have no \`model:\` — each would silently spend the Fable bucket. Add model: 'haiku'|'sonnet'|'opus' per the routing table; for a deliberate top-tier stage keep model omitted but put fable in its label (e.g. label: 'synthesize (fable/xhigh)'). Unrouted: $(printf '%s\n' "$bad" | head -n 5 | sed 's/^/• /' | tr '\n' ' ')" ;;
  esac
  exit 0
}

# --- spend: what this usage would have cost at API prices ---------------------------
# Every assistant entry in a transcript carries message.usage (input, cache writes by
# TTL, cache reads, output) and message.model. Rows are extracted per transcript into
# a cache keyed by path and re-extracted only when the file changes (a week of logs
# is hundreds of MB). Streaming writes repeat one message.id per content block, so
# rows are de-duplicated by id. Prices: Anthropic API list prices per million tokens,
# retrieved 2026-10-09 from platform.claude.com/docs/en/about-claude/pricing.
# Accounts: each row is attributed to the account active at its timestamp (cswap's
# switch log); plan cost comes from the account's rate-limit tier (Max 20x $200,
# Max 5x $100, Pro $20 a month), overridable in ~/.claude-swap-backup/plans.json
# as {"<slot>": <usd-per-month>}.
SPEND_DIR="$STATE_ROOT/cache/spend"
SPEND_PRICES_AS_OF="2026-10-09"

_spend_extract() { # _spend_extract <transcript> — TSV rows: id ts model in cw5 cw1h cr out speed session
  # Line by line, so one torn/partial line (a live session mid-write) is skipped
  # instead of failing the whole transcript.
  /usr/bin/grep -h '"output_tokens"' "$1" 2>/dev/null | jq -R -r '
    (fromjson? // empty)
    | select(.type == "assistant" and (.message.usage | type) == "object")
    | .message.usage as $u
    | (($u.cache_creation // null)) as $cc
    | [ (.message.id // .requestId // ""),
        ((.timestamp // "") | sub("\\.[0-9]+Z$"; "Z") | try fromdateiso8601 catch 0),
        (.message.model // "unknown"),
        ($u.input_tokens // 0),
        (if $cc != null then ($cc.ephemeral_5m_input_tokens // 0) else ($u.cache_creation_input_tokens // 0) end),
        (if $cc != null then ($cc.ephemeral_1h_input_tokens // 0) else 0 end),
        ($u.cache_read_input_tokens // 0),
        ($u.output_tokens // 0),
        ($u.speed // "standard"),
        (.sessionId // "") ] | @tsv' 2>/dev/null
}

_spend_plan_usd() { # _spend_plan_usd <slot> — monthly plan price for that account, or 0
  local slot="$1" tier v
  v="$(jq -r --arg s "$slot" '.[$s] | tonumber? // empty' "$STATE_ROOT/plans.json" 2>/dev/null)"
  [ -n "$v" ] && { printf '%s\n' "$v"; return 0; }
  tier="$(cat "$STATE_ROOT"/configs/.claude-config-"$slot"-*.json 2>/dev/null |
    jq -r '.oauthAccount | (.organizationRateLimitTier // .userRateLimitTier // .organizationType // "")' 2>/dev/null | head -n 1)"
  case "$tier" in
    *max_20x*) echo 200;; *max_5x*) echo 100;; *claude_max*) echo 100;; *pro*) echo 20;; *) echo 0;;
  esac
}

cmd_spend() {
  local days=7 a now start f key meta cur rows sw out acct_plans slot n
  while [ $# -gt 0 ]; do
    case "$1" in --days) days="${2:-7}"; shift;; --days=*) days="${1#--days=}";; esac
    shift
  done
  case "$days" in ''|*[!0-9]*) days=7;; esac
  [ "$days" -ge 1 ] || days=1
  now="$(date +%s)"; start=$(( now - days * 86400 ))
  mkdir -p "$SPEND_DIR" || exit 1
  rows="$(mktemp)"; sw="$(mktemp)"
  # 1. Extract (cached per transcript; re-extract only when size/mtime changed).
  find "$HOME/.claude/projects" -name '*.jsonl' -type f -mtime "-$((days + 1))" 2>/dev/null | while IFS= read -r f; do
    key="$(printf '%s' "$f" | /usr/bin/shasum | cut -c1-20)"
    meta="$(stat -f '%m %z' "$f" 2>/dev/null)"
    cur="$(cat "$SPEND_DIR/$key.meta" 2>/dev/null)"
    if [ "$meta" != "$cur" ] || [ ! -f "$SPEND_DIR/$key.tsv" ]; then
      if _spend_extract "$f" > "$SPEND_DIR/$key.tsv.tmp.$$"; then
        mv "$SPEND_DIR/$key.tsv.tmp.$$" "$SPEND_DIR/$key.tsv" && printf '%s\n' "$meta" > "$SPEND_DIR/$key.meta"
      else
        rm -f "$SPEND_DIR/$key.tsv.tmp.$$"
      fi
    fi
    cat "$SPEND_DIR/$key.tsv" 2>/dev/null
  done > "$rows"
  # Drop cache entries for transcripts gone or outside every window we would read.
  find "$SPEND_DIR" -type f -mtime +35 -delete 2>/dev/null
  # 2. Account timeline from cswap's switch log (local time), as "epoch slot".
  # "Added account N: email" (a login) activates N; "Added account N from <source>"
  # (token/import) does not, so only the colon form counts.
  grep -E ' - INFO - (Switched from account [0-9]+ to [0-9]+|Added account [0-9]+:)' "$STATE_ROOT/claude-swap.log" 2>/dev/null |
    while IFS= read -r a; do
      local d to e
      d="$(printf '%s' "$a" | cut -c1-19)"
      to="$(printf '%s' "$a" | sed -nE 's/.*(Switched from account [0-9]+ to |Added account )([0-9]+).*/\2/p')"
      e="$(date -j -f '%Y-%m-%d %H:%M:%S' "$d" +%s 2>/dev/null)" || continue
      printf '%s %s\n' "$e" "$to"
    done | sort -n > "$sw"
  # 3. Price + aggregate (awk: dedupe by id keeping the largest output, then sum).
  # macOS awk has no strftime: bucket days as local day numbers, name them in jq.
  local tzoff; tzoff="$(date +%z | awk '{ s = substr($0, 1, 1) == "-" ? -1 : 1; print s * (substr($0, 2, 2) * 3600 + substr($0, 4, 2) * 60) }')"
  out="$(sort -t "$(printf '\t')" -k2,2n "$rows" | awk -F '\t' -v start="$start" -v swf="$sw" -v tzoff="${tzoff:-0}" '
    function price(m, sp, tot,   r) {
      # returns "in out cw5 cw1h cr" USD per MTok
      if (m ~ /(fable|mythos)-5-1/)        r = "10 50 12.5 20 0.25"
      else if (m ~ /(fable|mythos)-5/)     r = "10 50 12.5 20 1.00"
      else if (m ~ /opus-5-5/)             r = (sp == "fast") ? "8 40 10 16 0.40" : "4 20 5 8 0.20"
      else if (m ~ /opus-5/)               r = (sp == "fast") ? "10 50 12.5 20 1.00" : "5 25 6.25 10 0.50"
      else if (m ~ /opus-4-(5|6|7|8)/)     r = (sp == "fast") ? "10 50 12.5 20 1.00" : "5 25 6.25 10 0.50"
      else if (m ~ /opus-4/)               r = "15 75 18.75 30 1.50"
      else if (m ~ /sonnet-5-5/)           r = "2 10 2.5 4 0.10"
      else if (m ~ /sonnet-5/)             r = "2 10 2.5 4 0.20"
      else if (m ~ /sonnet-4/)             r = "3 15 3.75 6 0.30"
      else if (m ~ /haiku-5-5/)            r = (tot > 100000) ? "0.50 2.50 0.625 1.00 0.05" : "0.10 0.50 0.125 0.20 0.01"
      else if (m ~ /haiku-4-5/)            r = "1 5 1.25 2 0.10"
      else if (m ~ /haiku-3-5|3-5-haiku/)  r = "0.80 4 1 1.6 0.08"
      else r = ""
      return r
    }
    BEGIN { ns = 0; while ((getline l < swf) > 0) { split(l, p, " "); st[ns] = p[1]; ss[ns] = p[2]; ns++ } }
    $2 >= start && $1 != "" {
      id = $1
      if (!(id in seen) || $8 > o[id]) {
        if (!(id in seen)) { order[++nid] = id; seen[id] = 1 }
        if (!(id in ts)) ts[id] = $2    # first-seen time keeps the sorted account sweep valid
        mdl[id] = $3; i[id] = $4; c5[id] = $5; c1[id] = $6; cr[id] = $7; o[id] = $8; spd[id] = $9; ses[id] = $10
      }
    }
    END {
      k = 0
      for (n = 1; n <= nid; n++) {
        id = order[n]; m = mdl[id]
        if (m == "<synthetic>") continue
        r = price(m, spd[id], i[id] + c5[id] + c1[id] + cr[id])
        if (r == "") { unk[m] += i[id] + o[id]; continue }
        split(r, pr, " ")
        usd = (i[id] * pr[1] + o[id] * pr[2] + c5[id] * pr[3] + c1[id] * pr[4] + cr[id] * pr[5]) / 1e6
        # account active at ts (switch timeline is sorted)
        while (k < ns && st[k] <= ts[id]) { cura = ss[k]; k++ }
        a = (cura == "") ? "?" : cura
        tot += usd; bym[m] += usd; bya[a] += usd; bys[ses[id]] += usd
        if (m ~ /fable|mythos/) fs[ses[id]] += usd
        day = int((ts[id] + tzoff) / 86400); byd[day] += usd
        tin += i[id]; tout += o[id]; tcw += c5[id] + c1[id]; tcr += cr[id]; msgs++
      }
      printf "T\t%.4f\t%d\t%d\t%d\t%d\t%d\n", tot, msgs, tin, tout, tcw, tcr
      for (m in bym) printf "M\t%s\t%.4f\n", m, bym[m]
      for (a in bya) printf "A\t%s\t%.4f\n", a, bya[a]
      for (s in bys) printf "S\t%s\t%.4f\t%.4f\n", s, bys[s], fs[s] + 0
      for (d in byd) printf "D\t%s\t%.4f\n", d, byd[d]
      for (m in unk) printf "U\t%s\t%d\n", m, unk[m]
    }')"
  rm -f "$rows" "$sw"
  # 4. Plans per account, then JSON.
  acct_plans="{}"
  for slot in $(jq -r '.accounts | to_entries[] | select(((.value.disabled // false) or ((.value.kind // "") == "api_key")) | not) | .key' "$STATE_ROOT/sequence.json" 2>/dev/null); do
    acct_plans="$(printf '%s' "$acct_plans" | jq -c --arg s "$slot" --argjson p "$(_spend_plan_usd "$slot")" \
      --arg l "$(_label_for "$slot")" '. + {($s): {plan: $p, label: $l}}')"
  done
  printf '%s\n' "$out" | jq -R -s -c --argjson days "$days" --argjson start "$start" --argjson now "$now" \
    --argjson plans "$acct_plans" --arg asof "$SPEND_PRICES_AS_OF" '
    split("\n") | map(select(. != "") | split("\t")) as $r
    | ($r | map(select(.[0] == "T")) | first // ["T","0","0","0","0","0","0"]) as $t
    | ($t[1] | tonumber) as $usd
    | ($days * 12 / 52 / 7) as $weeksOfMonth
    | {days: $days, from: ($start | todate), to: ($now | todate), usd: $usd,
       messages: ($t[2] | tonumber),
       tokens: {input: ($t[3] | tonumber), output: ($t[4] | tonumber), cacheWrite: ($t[5] | tonumber), cacheRead: ($t[6] | tonumber)},
       byModel: ($r | map(select(.[0] == "M") | {model: .[1], usd: (.[2] | tonumber)})
                 | sort_by(-.usd) | map(. + {share: (if $usd > 0 then (.usd / $usd * 100 | floor) else 0 end)})),
       byAccount: ($r | map(select(.[0] == "A") | .[1] as $s
                 | ($plans[$s] // {plan: 0, label: (if $s == "?" then "unknown" else "account " + $s end)}) as $p
                 | {slot: $s, label: $p.label, usd: (.[2] | tonumber),
                    planUsd: (($p.plan * $weeksOfMonth * 100 | round) / 100)}
                 | . + {ratio: (if .planUsd > 0 then ((.usd / .planUsd * 10 | round) / 10) else null end)})
                 | sort_by(-.usd)),
       byDay: ($r | map(select(.[0] == "D") | {day: ((.[1] | tonumber) * 86400 | todate | .[0:10]), usd: (.[2] | tonumber)}) | sort_by(.day)),
       topSessions: ($r | map(select(.[0] == "S") | {sessionId: .[1], usd: (.[2] | tonumber),
                     fableShare: (if (.[2] | tonumber) > 0 then ((.[3] | tonumber) / (.[2] | tonumber) * 100 | floor) else 0 end)})
                     | sort_by(-.usd) | .[:5]),
       unpricedModels: ($r | map(select(.[0] == "U") | {model: .[1], tokens: (.[2] | tonumber)})),
       pricesAsOf: $asof}
    # What the user pays: the plan of every registered account, idle ones included.
    | . + {planUsd: (([$plans | to_entries[] | .value.plan] | add // 0) * $weeksOfMonth * 100 | round / 100)}
    | . + {ratio: (if .planUsd > 0 then ((.usd / .planUsd * 10 | round) / 10) else null end)}' \
    > "$STATE_ROOT/cache/spend.json.tmp.$$"
  # Replace the cache only with a real result, so the statusline never loses the last good value.
  if jq -e '.usd' "$STATE_ROOT/cache/spend.json.tmp.$$" >/dev/null 2>&1; then
    cat "$STATE_ROOT/cache/spend.json.tmp.$$"
    mv "$STATE_ROOT/cache/spend.json.tmp.$$" "$STATE_ROOT/cache/spend.json"
  else
    rm -f "$STATE_ROOT/cache/spend.json.tmp.$$"; echo '{"error":"spend-failed"}'; return 1
  fi
}

cmd_schedule_kill() {
  echo "phase-2 feature not enabled" >&2
  exit 1
}

main() {
  local sub="${1:-}"
  [ $# -gt 0 ] && shift
  case "$sub" in
    whoami)        cmd_whoami "$@";;
    sessions)      cmd_sessions "$@";;
    preflight)     cmd_preflight "$@";;
    path)          cmd_path "$@";;
    flag)          cmd_flag "$@";;
    status)        cmd_status "$@";;
    cancel)        cmd_cancel "$@";;
    history)       cmd_history "$@";;
    restore)       cmd_restore "$@";;
    auto)          cmd_auto "$@";;
    keepalive)     cmd_keepalive "$@";;
    logins)        cmd_logins "$@";;
    forecast)      cmd_forecast "$@";;
    browser)       cmd_browser "$@";;
    prewarm)       cmd_prewarm "$@";;
    token)         cmd_token "$@";;
    remote)        cmd_remote "$@";;
    fleet)         cmd_fleet "$@";;
    artifacts)
      case "${1:-}" in
        find|list) shift; _art_find "$@"; exit $?;;
        link)      shift; _art_link "$@"; exit $?;;
        index)     _art_backfill; exit $?;;
        carry)     shift; _art_carry "$@"; exit $?;;
        *)         cmd_artifacts "$@";;
      esac;;
    artifact-log)  cmd_artifact_log "$@";;
    ratelimit)     cmd_ratelimit "$@";;
    server-retry)  cmd_server_retry "$@";;
    loop-guard)    cmd_loop_guard "$@";;
    checkpoint)    cmd_checkpoint "$@";;
    budget)        cmd_budget "$@";;
    mem)           cmd_mem "$@";;
    login)         cmd_login "$@";;
    version-check) cmd_version_check "$@";;
    codex-usage)   cmd_codex_usage "$@";;
    codex-ready)   cmd_codex_ready "$@";;
    codex-review)  cmd_codex_review "$@";;
    route-guard)   cmd_route_guard "$@";;
    spend)         cmd_spend "$@";;
    schedule-kill) cmd_schedule_kill "$@";;
    *) usage;;
  esac
}

if [ "${BASH_SOURCE[0]:-}" = "$0" ]; then
  main "$@"
fi
